// 頁面: 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, } #[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 { 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, 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::().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, next_id: &mut u64, bot: u64, args: &Value, now: i64) -> Result { 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 { 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::>().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 { 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 { 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::>()}) } /// One older version as a whole page (for 「看上一版」). pub fn version_of(p: &Page, revision: u32) -> Option { 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 { let mut out = vec![]; let mut cur: Option = 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 { let mut out = vec![]; for c in calls.iter().filter(|c| c.name == "write_page") { if let Some(id) = serde_json::from_str::(&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, 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); } }