AnyBot/src/pages.rs

420 lines
21 KiB
Rust
Raw Normal View History

// 頁面: 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);
}
}