AnyBot/src/pages.rs

420 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 頁面: pages a bot makes and keeps up to date — a tracker ("台灣 Tesla FSD 追蹤"), a report, a
// comparison — like the original's canvases. The bot writes Markdown (with tables, Mermaid charts
// and a few page blocks: ```stats tiles, ```steps progress, > [!NOTE] call-outs); the chat shows a
// card under the reply, `[標題](page:12)` links open it, and a routine can update it later (each
// update keeps the previous version). Pages live in State, so a brain's pages reach its screens
// and phones with everything else.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Older versions kept per page.
pub const KEEP_VERSIONS: usize = 10;
pub const MAX_BODY: usize = 200_000;
const MAX_TITLE: usize = 120;
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)]
pub struct Page {
pub id: u64,
/// The bot that made it (and may change it).
pub bot: u64,
pub title: String,
/// One line under the title on the card ("現況:還在審查第一步").
#[serde(default)]
pub summary: String,
/// Markdown.
pub body: String,
/// ms since 1970.
pub created: i64,
pub updated: i64,
/// 1 for a new page; +1 on every change.
#[serde(default = "one")]
pub revision: u32,
/// What changed last time (the bot's words), shown as 「更新:…」.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
/// Older versions, oldest first.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<Version>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)]
pub struct Version {
pub revision: u32,
pub at: i64,
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub summary: String,
pub body: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
fn one() -> u32 {
1
}
pub fn specs() -> Vec<Value> {
vec![
json!({"name": "write_page", "description": "Make or update a page: a lasting, nicely laid-out document the person can open from the chat and that you keep up to date — a tracker (e.g. 「台灣 Tesla FSD 追蹤」), a report, a comparison, a plan, research notes. Use it whenever the person asks you to track, follow, summarise or compare something they will come back to; then link it in your reply as [標題](page:ID). With an id (or the same title) it replaces that page's content and keeps the old version. Write Markdown: headings, short paragraphs, lists, tables, links to sources, Mermaid charts (```mermaid: pie, xychart-beta bar/line, timeline, gantt, flowchart), and these page blocks:\n```stats\n狀態 | 審查中 | 第 1 步\n預計開放 | 2027 Q2 | 媒體推估\n```\n```steps\n[x] 道路測試計畫審查 | 7/9 月開過兩次會\n[>] 道路測試 | 等交通部核定\n[ ] 正式開放\n```\n> [!NOTE] / [!TIP] / [!WARNING] / [!IMPORTANT] call-outs.\nStart with the answer (現況), then the details, then 來源 with links and dates. Keep the whole page current when updating (not a log), and say what changed in `note`.",
"parameters": {"type": "object", "properties": {
"title": {"type": "string", "description": "Short title, e.g. 台灣 Tesla FSD 追蹤"},
"content": {"type": "string", "description": "The whole page in Markdown (without the title)"},
"summary": {"type": "string", "description": "One line for the card: the current state in a few words"},
"note": {"type": "string", "description": "When updating: what changed, in one short sentence"},
"id": {"type": "integer", "description": "The page to update (from list_pages or an earlier write_page)"}},
"required": ["title", "content"]}}),
json!({"name": "read_page", "description": "Read one of your pages (its Markdown), e.g. before updating it.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}, "title": {"type": "string"}}}}),
json!({"name": "list_pages", "description": "List the pages you made (id, title, last update).", "parameters": {"type": "object", "properties": {}}}),
]
}
pub const NAMES: [&str; 3] = ["write_page", "read_page", "list_pages"];
pub fn label(name: &str) -> &'static str {
match name {
"write_page" => "寫頁面",
"read_page" => "讀頁面",
_ => "查看頁面",
}
}
pub fn describe(name: &str, args: &Value) -> String {
match name {
"write_page" | "read_page" => args["title"].as_str().map(String::from).or_else(|| args["id"].as_u64().map(|i| format!("#{i}"))).unwrap_or_default(),
_ => String::new(),
}
}
fn find<'a>(pages: &'a [Page], bot: u64, id: Option<u64>, title: &str) -> Option<&'a Page> {
match id {
Some(i) => pages.iter().find(|p| p.id == i),
None => pages.iter().find(|p| p.bot == bot && same_title(&p.title, title)),
}
}
fn same_title(a: &str, b: &str) -> bool {
let n = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
!b.trim().is_empty() && n(a) == n(b)
}
/// What write_page did: the page's id and the text for the model.
pub struct Written {
pub id: u64,
pub text: String,
}
/// write_page: make a page, or replace one (by id, or the same title from this bot).
pub fn write(pages: &mut Vec<Page>, next_id: &mut u64, bot: u64, args: &Value, now: i64) -> Result<Written, String> {
let title: String = args["title"].as_str().unwrap_or("").trim().chars().take(MAX_TITLE).collect();
let body = args["content"].as_str().unwrap_or("").trim().to_string();
let summary: String = args["summary"].as_str().unwrap_or("").trim().chars().take(200).collect();
let note: String = args["note"].as_str().unwrap_or("").trim().chars().take(300).collect();
if title.is_empty() {
return Err("頁面要有標題".into());
}
if body.is_empty() {
return Err("頁面內容是空的".into());
}
if body.len() > MAX_BODY {
return Err(format!("頁面太長了(最多 {} 字)。請精簡,或拆成幾頁。", MAX_BODY));
}
let id = args["id"].as_u64();
let existing = find(pages, bot, id, &title).map(|p| p.id);
if id.is_some() && existing.is_none() {
return Err(format!("找不到頁面 #{}。用 list_pages 看有哪些頁面,或不帶 id 建立新的。", id.unwrap_or(0)));
}
match existing {
Some(pid) => {
let p = pages.iter_mut().find(|p| p.id == pid).unwrap();
if p.bot != bot {
return Err("這是別的 Bot 的頁面,不能改".into());
}
if p.body == body && p.title == title && (summary.is_empty() || p.summary == summary) {
return Ok(Written { id: pid, text: format!("頁面「{}」內容沒有變化,沒有更新。連結:[{}](page:{})", p.title, p.title, pid) });
}
p.history.push(Version { revision: p.revision, at: p.updated, title: p.title.clone(), summary: p.summary.clone(), body: std::mem::take(&mut p.body), note: std::mem::take(&mut p.note) });
if p.history.len() > KEEP_VERSIONS {
let extra = p.history.len() - KEEP_VERSIONS;
p.history.drain(..extra);
}
p.title = title;
p.body = body;
if !summary.is_empty() {
p.summary = summary;
}
p.note = note;
p.revision += 1;
p.updated = now;
Ok(Written { id: pid, text: format!("已更新頁面「{}」(第 {} 版,上一版留著)。在回答裡用 [{}](page:{}) 連到它。", p.title, p.revision, p.title, pid) })
}
None => {
let pid = *next_id;
*next_id += 1;
let text = format!("已建立頁面「{title}」。在回答裡用 [{title}](page:{pid}) 連到它;要更新時用 write_page 帶 id {pid}。");
pages.push(Page { id: pid, bot, title, summary, body, created: now, updated: now, revision: 1, note, history: vec![] });
Ok(Written { id: pid, text })
}
}
}
pub fn read(pages: &[Page], bot: u64, args: &Value) -> Result<String, String> {
let title = args["title"].as_str().unwrap_or("");
let p = find(pages, bot, args["id"].as_u64(), title).ok_or("找不到這個頁面。用 list_pages 看有哪些。")?;
Ok(format!("# {}\n(頁面 #{},第 {} 版,更新於 {}{})\n\n{}", p.title, p.id, p.revision, when(p.updated), if p.summary.is_empty() { String::new() } else { format!(";摘要:{}", p.summary) }, p.body))
}
pub fn list(pages: &[Page], bot: u64) -> String {
let mine: Vec<&Page> = pages.iter().filter(|p| p.bot == bot).collect();
if mine.is_empty() {
return "你還沒有做任何頁面。".into();
}
mine.iter().map(|p| format!("#{} 「{}」 第 {} 版,更新於 {}{}", p.id, p.title, p.revision, when(p.updated), if p.summary.is_empty() { String::new() } else { format!(" — {}", p.summary) })).collect::<Vec<_>>().join("\n")
}
/// "11/5 09:33" in the local time zone.
pub fn when(ms: i64) -> String {
use chrono::TimeZone;
chrono::Local.timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default()
}
/// 「剛剛」「5 分鐘前」「昨天 09:33」「11/3」.
pub fn ago(ms: i64, now: i64) -> String {
use chrono::TimeZone;
let d = (now - ms).max(0) / 1000;
if d < 60 {
return "剛剛".into();
}
if d < 3600 {
return format!("{} 分鐘前", d / 60);
}
let (Some(t), Some(n)) = (chrono::Local.timestamp_millis_opt(ms).single(), chrono::Local.timestamp_millis_opt(now).single()) else { return String::new() };
let days = (n.date_naive() - t.date_naive()).num_days();
match days {
0 => format!("今天 {}", t.format("%H:%M")),
1 => format!("昨天 {}", t.format("%H:%M")),
_ if t.format("%Y").to_string() == n.format("%Y").to_string() => t.format("%-m/%-d").to_string(),
_ => t.format("%Y/%-m/%-d").to_string(),
}
}
/// A few lines of plain text for the card: the summary, then the page's first words (no
/// Markdown marks, no code or chart blocks).
pub fn preview(p: &Page, lines: usize) -> Vec<String> {
let mut out = vec![];
if !p.summary.is_empty() {
out.push(p.summary.clone());
}
let mut fence = false;
for l in p.body.lines() {
let t = l.trim();
if t.starts_with("```") {
fence = !fence;
continue;
}
if fence || t.is_empty() || t.starts_with('|') || t.starts_with("---") || t.starts_with('#') {
continue;
}
let t = t.trim_start_matches(['-', '*', '>', ' ']).trim();
let t = callout(t).map(|(_, rest)| rest).unwrap_or_else(|| t.to_string());
let t = plain(&t);
if !t.is_empty() && !out.contains(&t) {
out.push(t);
}
if out.len() >= lines {
break;
}
}
out
}
/// Markdown marks off: **b**, `c`, [text](url) → text.
pub fn plain(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find('[') {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
match (after.find("]("), after.find(')')) {
(Some(a), Some(b)) if a < b => {
out.push_str(&after[..a]);
rest = &after[b + 1..];
}
_ => {
out.push('[');
rest = after;
}
}
}
out.push_str(rest);
out.replace("**", "").replace('`', "").replace("__", "")
}
/// The id in a `page:12` link.
pub fn link_id(url: &str) -> Option<u64> {
url.strip_prefix("page:").or_else(|| url.strip_prefix("anybot://page/")).and_then(|s| s.trim_matches('/').parse().ok())
}
/// For the phone: the card in a chat, and the whole page.
pub fn card_json(p: &Page, now: i64) -> Value {
json!({"id": p.id, "title": p.title, "preview": preview(p, 4), "revision": p.revision, "updated": ago(p.updated, now), "note": p.note})
}
pub fn page_json(p: &Page, bot_name: &str, now: i64) -> Value {
json!({"id": p.id, "title": p.title, "summary": p.summary, "body": p.body, "revision": p.revision, "note": p.note,
"updated": when(p.updated), "updated_ago": ago(p.updated, now), "created": when(p.created), "bot": bot_name,
"versions": p.history.iter().rev().map(|v| json!({"revision": v.revision, "at": when(v.at), "note": v.note})).collect::<Vec<_>>()})
}
/// One older version as a whole page (for 「看上一版」).
pub fn version_of(p: &Page, revision: u32) -> Option<Page> {
p.history.iter().find(|v| v.revision == revision).map(|v| Page { id: p.id, bot: p.bot, title: v.title.clone(), summary: v.summary.clone(), body: v.body.clone(), created: p.created, updated: v.at, revision: v.revision, note: v.note.clone(), history: vec![] })
}
/// The page's ```mermaid blocks, in order (the phone asks for them one by one as SVG).
pub fn mermaid_blocks(body: &str) -> Vec<String> {
let mut out = vec![];
let mut cur: Option<String> = None;
for l in body.lines() {
let t = l.trim_start();
match &mut cur {
None if t.starts_with("```mermaid") => cur = Some(String::new()),
Some(s) if t.starts_with("```") => {
out.push(std::mem::take(s));
cur = None;
}
Some(s) => {
s.push_str(l);
s.push('\n');
}
None => {}
}
}
out
}
/// The pages written in one reply (its write_page calls), each once, in order.
pub fn ids_in(calls: &[crate::model::ToolCall]) -> Vec<u64> {
let mut out = vec![];
for c in calls.iter().filter(|c| c.name == "write_page") {
if let Some(id) = serde_json::from_str::<Value>(&c.args).ok().and_then(|v| v["id"].as_u64()) {
if !out.contains(&id) {
out.push(id);
}
}
}
out
}
/// ```stats lines: 標籤 | 數值 | 說明
pub fn stats(src: &str) -> Vec<(String, String, String)> {
src.lines()
.filter_map(|l| {
let c: Vec<&str> = l.split('|').map(str::trim).collect();
(c.len() >= 2 && !c[0].is_empty()).then(|| (plain(c[0]), plain(c[1]), c.get(2).map(|s| plain(s)).unwrap_or_default()))
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Step {
Done,
Now,
Todo,
}
/// ```steps lines: [x] 名稱 | 說明 ([>] = now, [ ] = to do)
pub fn steps(src: &str) -> Vec<(Step, String, String)> {
src.lines()
.filter_map(|l| {
let t = l.trim().trim_start_matches(['-', '*']).trim();
let (st, rest) = if let Some(r) = t.strip_prefix("[x]").or_else(|| t.strip_prefix("[X]")).or_else(|| t.strip_prefix("[✓]")) {
(Step::Done, r)
} else if let Some(r) = t.strip_prefix("[>]").or_else(|| t.strip_prefix("[~]")).or_else(|| t.strip_prefix("[-]")) {
(Step::Now, r)
} else if let Some(r) = t.strip_prefix("[ ]") {
(Step::Todo, r)
} else {
return None;
};
let mut c = rest.splitn(2, '|');
let name = plain(c.next().unwrap_or("").trim());
(!name.is_empty()).then(|| (st, name, plain(c.next().unwrap_or("").trim())))
})
.collect()
}
/// `> [!WARNING] text` → ("warning", text).
pub fn callout(first_line: &str) -> Option<(&'static str, String)> {
let t = first_line.trim();
let rest = t.strip_prefix("[!")?;
let end = rest.find(']')?;
let kind = match rest[..end].to_ascii_uppercase().as_str() {
"NOTE" | "INFO" => "note",
"TIP" => "tip",
"WARNING" | "CAUTION" => "warning",
"IMPORTANT" => "important",
_ => return None,
};
Some((kind, rest[end + 1..].trim().to_string()))
}
/// A sample tracking page (screenshots, and the look the bots are asked for).
pub fn demo(now: i64) -> (Vec<Page>, u64, u64) {
let body = "## 現況\n台灣 FSD **現在還不能用**:交通部把它當 L2 輔助駕駛,走「新技術駕駛系統審查」,不用修法;目前卡在第 1 步,路測計畫還沒核准。\n\n```stats\n目前階段 | 第 1 步 | 道路測試計畫審查\n預計開放 | 2027 Q2 | 媒體推估,官方未公布\n月費 | 未公布 | 網路數字是美國價換算\n你的車 | HW4 | 柏林廠,硬體不卡\n```\n\n## 審查進度\n```steps\n[x] 專家審查會議 | 7 月、9 月各開一次\n[>] 道路測試計畫審查 | 特斯拉依會議結論補件中\n[ ] 道路測試 | 計畫核定後才開始\n[ ] 正式開放\n```\n\n> [!WARNING] 你 12 月交車時多半還不能用,現在先別花錢。等正式開放後先訂閱一個月最划算。\n\n## 時間線\n| 日期 | 消息 | 來源 |\n|---|---|---|\n| 11/4 | 交通部:希望年底前完成相關審查,但不保證年底開放 | NOWnews |\n| 10/2 | 特斯拉:7–9 月完成第一輪技術審查,提出修改後的路測計畫 | 特斯拉 |\n| 6/30 | FSD 買斷停賣 | 特斯拉官網 |\n\n```mermaid\npie title 媒體預估開放時間\n \"2026 年底\" : 1\n \"2027 上半年\" : 3\n \"2027 下半年\" : 4\n```\n\n## 來源\n- [NOWnews 專訪交通部監理司](https://www.nownews.com)(11/4)\n- [車安中心 新技術駕駛系統審查流程](https://www.vscc.org.tw)\n";
let mut pages = vec![];
let mut next = 1;
let first = body.replace("交通部:希望年底前完成相關審查,但不保證年底開放", "(還沒有新消息)");
let _ = write(&mut pages, &mut next, 1, &json!({"title": "台灣 Tesla FSD 追蹤", "content": first, "summary": "還不能用:審查第一步,路測計畫未核准"}), now - 86_400_000);
let id = write(&mut pages, &mut next, 1, &json!({"title": "台灣 Tesla FSD 追蹤", "content": body, "note": "交通部 11/4 的新說法:希望年底前完成審查"}), now - 3_600_000).map(|w| w.id).unwrap_or(1);
(pages, next, id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_makes_then_updates_and_keeps_versions() {
let mut pages = vec![];
let mut next = 1;
let w = write(&mut pages, &mut next, 7, &json!({"title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n還在審查", "summary": "審查第一步"}), 1000).unwrap();
assert_eq!(w.id, 1);
assert!(w.text.contains("page:1"));
// Same title, no id: the same page.
let w2 = write(&mut pages, &mut next, 7, &json!({"title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n交通部有新說法", "note": "交通部新說法"}), 2000).unwrap();
assert_eq!(w2.id, 1);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].revision, 2);
assert_eq!(pages[0].summary, "審查第一步", "kept when not given");
assert_eq!(pages[0].history.len(), 1);
assert_eq!(pages[0].history[0].body, "# 現況\n還在審查");
// Nothing changed: no new version.
let w3 = write(&mut pages, &mut next, 7, &json!({"id": 1, "title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n交通部有新說法"}), 3000).unwrap();
assert!(w3.text.contains("沒有變化"));
assert_eq!(pages[0].revision, 2);
// Another bot can't change it; an unknown id is an error.
assert!(write(&mut pages, &mut next, 8, &json!({"id": 1, "title": "x", "content": "y"}), 4000).is_err());
assert!(write(&mut pages, &mut next, 7, &json!({"id": 99, "title": "x", "content": "y"}), 4000).is_err());
for i in 0..15 {
write(&mut pages, &mut next, 7, &json!({"id": 1, "title": "台灣 Tesla FSD 追蹤", "content": format!("v{i}")}), 5000 + i).unwrap();
}
assert_eq!(pages[0].history.len(), KEEP_VERSIONS);
assert!(version_of(&pages[0], pages[0].revision - 1).is_some());
}
#[test]
fn preview_and_blocks() {
let p = Page { title: "t".into(), summary: "還不能用".into(), body: "# 現況\n\n```stats\n狀態 | 審查中\n```\n交通部說**年底前**完成審查,見 [來源](https://x.com)。\n\n| a | b |\n|---|---|\n".into(), ..Default::default() };
assert_eq!(preview(&p, 3), vec!["還不能用", "交通部說年底前完成審查,見 來源。"]);
assert_eq!(stats("狀態 | 審查中 | 第 1 步\n預計 | 2027"), vec![("狀態".into(), "審查中".into(), "第 1 步".into()), ("預計".into(), "2027".into(), String::new())]);
let s = steps("[x] 審查 | 開過兩次會\n[>] 路測\n[ ] 開放\n不是步驟");
assert_eq!(s.len(), 3);
assert_eq!((s[0].0, s[1].0, s[2].0), (Step::Done, Step::Now, Step::Todo));
assert_eq!(s[0].2, "開過兩次會");
assert_eq!(callout("[!WARNING] 先別花錢"), Some(("warning", "先別花錢".into())));
assert_eq!(link_id("page:12"), Some(12));
assert_eq!(link_id("https://x"), None);
}
}