diff --git a/mobile/index.html b/mobile/index.html index 4357b76..a9da53c 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -90,6 +90,61 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400} .card .t{font-weight:600}.card .s{color:var(--text3);font-size:13px;margin-bottom:6px} .card .r{display:flex;justify-content:space-between;gap:10px;font-size:13.5px;padding:3px 0;border-top:1px solid var(--line)} .card .r span:first-child{color:var(--text2)} +/* 頁面: the card in a chat, and the page */ +.pcard{border:1px solid var(--line);border-radius:16px;margin:8px 0;background:var(--surface);max-width:520px;overflow:hidden;cursor:pointer;-webkit-tap-highlight-color:transparent} +.pcard:active{background:var(--raise)} +.pcard .top{padding:14px 16px 10px} +.pcard .t{font-weight:700;font-size:17px;margin-bottom:6px} +.pcard .l{font-size:13.5px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.55} +.pcard .foot{display:flex;gap:12px;align-items:center;padding:10px 16px;border-top:1px solid var(--line)} +.pcard .n{font-size:14px}.pcard .m{font-size:12px;color:var(--text3);padding:0;display:block} +.pcard .note{font-size:12px;color:var(--text2);padding:0 16px 10px} +.pglyph{flex:none} +a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;cursor:pointer} +.page{padding:18px 18px calc(env(safe-area-inset-bottom) + 40px);max-width:760px;margin:0 auto;line-height:1.65;font-size:15.5px} +.page h1.pt{font-size:25px;line-height:1.3;margin:0 0 6px} +.page .meta{color:var(--text3);font-size:12.5px;margin-bottom:12px} +.page .sum{font-size:16px;margin:0 0 6px} +.page .chg{font-size:12.5px;color:var(--text2);margin-bottom:8px} +.page .old{background:var(--raise);border-radius:8px;padding:8px 10px;font-size:13px;color:var(--text2);margin-bottom:10px} +.page h2{font-size:19px;margin:1.3em 0 .4em}.page h3{font-size:16.5px;margin:1.1em 0 .3em}.page h4{font-size:15px;margin:1em 0 .3em} +.page p{margin:0 0 .75em} +.page ul,.page ol{margin:.2em 0 .8em;padding-left:1.3em}.page li{margin:.15em 0} +.page li.task{list-style:none;margin-left:-1.2em} +.page a{color:var(--accent)} +.page code{font:13px var(--mono);background:var(--raise);padding:1px 5px;border-radius:5px} +.page pre{background:var(--raise);padding:10px 12px;border-radius:10px;overflow-x:auto;font:12.5px/1.45 var(--mono)} +.page pre code{background:none;padding:0} +.page hr{border:0;border-top:1px solid var(--line);margin:1.2em 0} +.page blockquote{margin:0 0 .8em;padding-left:12px;border-left:3px solid var(--line);color:var(--text2)} +.tw{overflow-x:auto;margin:0 0 1em;border:1px solid var(--line);border-radius:10px} +.page table{border-collapse:collapse;width:100%;font-size:14px} +.page th,.page td{padding:7px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top} +.page th{background:var(--raise);font-weight:600;white-space:nowrap} +.page tr:last-child td{border-bottom:0} +.stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin:0 0 1em} +.stat{background:var(--raise);border-radius:12px;padding:10px 12px} +.stat .k{font-size:12px;color:var(--text2)}.stat .v{font-size:21px;font-weight:700;line-height:1.3}.stat .s{font-size:11.5px;color:var(--text3)} +.steps{margin:0 0 1em;padding:0;list-style:none} +.steps li{position:relative;padding:0 0 14px 30px} +.steps li:before{content:"";position:absolute;left:8px;top:20px;bottom:-2px;width:2px;background:var(--line)} +.steps li:last-child:before{display:none} +.steps li.done:before{background:var(--ok)} +.steps .dot{position:absolute;left:0;top:3px;width:18px;height:18px;border-radius:50%;box-sizing:border-box;border:1.5px solid var(--text3);background:var(--bg);display:grid;place-items:center;color:#fff;font-size:11px;animation:none;margin:0} +.steps .done .dot{background:var(--ok);border-color:var(--ok)} +.steps .now .dot{border:2.5px solid var(--accent)} +.steps .now .dot:after{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent)} +.steps .todo .nm{color:var(--text3)}.steps .now .nm{font-weight:700} +.steps .nt{font-size:13px;color:var(--text2)} +.call{border-radius:10px;padding:10px 12px 10px 14px;margin:0 0 1em;border-left:3px solid var(--accent);background:rgba(22,128,243,.08)} +.call .ct{font-size:12.5px;font-weight:700;color:var(--accent);margin-bottom:2px} +.call.warning{border-color:#e08a00;background:rgba(224,138,0,.10)}.call.warning .ct{color:#e08a00} +.call.important{border-color:#d03b3b;background:rgba(208,59,59,.09)}.call.important .ct{color:#d03b3b} +.call.tip{border-color:var(--ok);background:rgba(46,160,67,.09)}.call.tip .ct{color:var(--ok)} +.call p:last-child{margin:0} +.chart{margin:0 0 1em;text-align:center}.chart img{max-width:100%;height:auto} +.chart .wait{color:var(--text3);font-size:13px;padding:20px} +#pver{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:4px 6px;font-size:13px;color:var(--text);max-width:42%} .typing{display:inline-flex;gap:4px;padding:8px 4px} .typing i{width:6px;height:6px;border-radius:50%;background:var(--text3);animation:b 1.2s infinite} .typing i:nth-child(2){animation-delay:.15s}.typing i:nth-child(3){animation-delay:.3s} @@ -195,6 +250,15 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400} +
+
+ +

頁面

+ +
+
+
+
@@ -405,6 +469,7 @@ function md(text) { let h = esc(part) .replace(/`([^`\n]+)`/g, "$1") .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `${GLYPH(14)}${t}`) .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '$1') .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1$2'); const out = []; let list = null; @@ -431,7 +496,7 @@ function renderMsgs() { return `
${m.wake ? `
${ic("alarm-clock", 13)} 例行任務
` : ""}${m.text ? `
${esc(m.text)}
` : ""}${atts}
`; } if (m.summary) return `
(較早的對話已整理成摘要)
`; - const tools = (m.tools || []).map((t) => t.card ? card(t.card) : `
${ST[t.status] || "·"}${esc(t.label)}${esc(t.detail)}
`).join(""); + const tools = (m.tools || []).map((t) => t.page ? pageCard(t.page) : t.card ? card(t.card) : `
${ST[t.status] || "·"}${esc(t.label)}${esc(t.detail)}
`).join(""); const last = i === S.msgs.length - 1; const body = m.text ? `
${md(m.text)}
` : (last && busy && !tools ? `
` : ""); const who = m.author ? `
${esc(m.author)}
` : ""; @@ -448,6 +513,128 @@ function card(c) { const rows = (c.rows || []).map((r) => `
${esc(r.label)}${esc(r.value)}
`).join(""); return `
${esc(c.title)}
${c.subtitle ? `
${esc(c.subtitle)}
` : ""}${rows}${c.link ? `` : ""}
`; } +// ---------- 頁面 ---------- +const GLYPH = (h = 34) => ``; +function pageCard(p) { + const lines = (p.preview || []).map((l, i, a) => `
${esc(l)}
`).join(""); + const meta = `頁面 · ${esc(p.updated)}${p.revision > 1 ? " · 已更新" : ""}`; + return `
${esc(p.title)}
${lines}
${p.revision > 1 && p.note ? `
最近更新:${esc(p.note)}
` : ""}
${GLYPH()}
${esc(p.title)}
${meta}
`; +} +document.addEventListener("click", (e) => { + const el = e.target.closest && e.target.closest("[data-page]"); + if (el) { e.preventDefault(); openPage(+el.dataset.page); } +}); +async function openPage(id, rev) { + if (S.view !== "page") S.pageBack = S.view; + S.page = id; show("page"); $("pscroll").scrollTop = 0; + if (!rev) $("page").innerHTML = `
載入中…
`; + try { + const v = await api(base() + "api/pages/" + id + (rev ? "?rev=" + rev : "")); + $("page-name").textContent = v.title; + const old = v.revision !== v.latest; + const sel = $("pver"); + if ((v.versions || []).length) { + sel.innerHTML = `` + v.versions.map((x) => ``).join(""); + sel.value = old ? String(v.revision) : ""; sel.style.display = ""; + } else sel.style.display = "none"; + let head = `

${esc(v.title)}

${esc(v.bot)} · 更新於 ${esc(v.updated)}${v.latest > 1 ? ` · 第 ${v.revision} 版` : ""}
`; + if (old) head += `
你在看舊的版本。回到最新版
`; + else if (v.summary) head += `

${esc(v.summary)}

`; + if (v.note && v.revision > 1) head += `
這一版更新了:${esc(v.note)}
`; + $("page").innerHTML = head + pageMd(v.body, id, old ? v.revision : 0); + loadCharts(); + } catch (e) { $("page").innerHTML = `
${esc(e.message)}
`; } +} +$("pver").onchange = () => openPage(S.page, +$("pver").value || 0); +$("b-back3").onclick = () => { show(S.pageBack && S.pageBack !== "page" ? S.pageBack : "list"); if (S.view === "chat") loadChat(); }; +const dark = () => matchMedia("(prefers-color-scheme: dark)").matches; +async function loadCharts() { + for (const el of document.querySelectorAll(".chart[data-src]")) { + const src = el.dataset.src; el.removeAttribute("data-src"); + try { + const r = await api(base() + src + (src.includes("?") ? "&" : "?") + "dark=" + (dark() ? 1 : 0), {raw: true}); + if (!r.ok) throw new Error(); + el.innerHTML = `圖表`; + } catch (_) { el.innerHTML = `
這張圖畫不出來
`; } + } +} +// Inline Markdown: code, bold, italics, strike, links (page:N opens the page). +function inl(s) { + return esc(s) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1$2") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `${t}`) + .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '$1') + .replace(/(^|[\s((])(https?:\/\/[^\s<))]+)/g, '$1$2'); +} +const CALL = {note: "說明", tip: "提示", warning: "注意", important: "重要"}; +// A page's Markdown: headings, paragraphs, lists and tasks, tables, quotes and call-outs, code, +// Mermaid charts (drawn by the AnyBot as SVG), ```stats tiles and ```steps progress. +function pageMd(text, id, rev) { + const L = String(text || "").replace(/\r/g, "").split("\n"); + const out = []; let i = 0, chart = 0; + const cells = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); + while (i < L.length) { + const l = L[i], t = l.trim(); + if (!t) { i++; continue; } + const fence = t.match(/^```\s*([\w-]*)/); + if (fence) { + const lang = fence[1].toLowerCase(); const body = []; i++; + while (i < L.length && !L[i].trim().startsWith("```")) body.push(L[i++]); + i++; + if (lang === "mermaid") { out.push(`
圖表載入中…
`); continue; } + if (lang === "stats") { + const tiles = body.map(cells).filter((c) => c.length >= 2 && c[0]).map((c) => `
${inl(c[0])}
${inl(c[1])}
${c[2] ? `
${inl(c[2])}
` : ""}
`); + if (tiles.length) { out.push(`
${tiles.join("")}
`); continue; } + } + if (lang === "steps") { + const items = body.map((b) => b.trim().replace(/^[-*]\s*/, "").match(/^\[(x|X|✓|>|~|-| )\]\s*(.*)$/)).filter(Boolean).map((m) => { + const st = /[xX✓]/.test(m[1]) ? "done" : /[>~-]/.test(m[1]) ? "now" : "todo"; + const [nm, nt] = m[2].split(/\s*\|\s*/, 2); + return `
  • ${st === "done" ? "✓" : ""}
    ${inl(nm)}
    ${nt ? `
    ${inl(nt)}
    ` : ""}
  • `; + }); + if (items.length) { out.push(``); continue; } + } + out.push(`
    ${esc(body.join("\n"))}
    `); continue; + } + const h = t.match(/^(#{1,6})\s+(.*)$/); + if (h) { const n = Math.min(h[1].length + 1, 4); out.push(`${inl(h[2])}`); i++; continue; } + if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) { out.push("
    "); i++; continue; } + if (t.startsWith("|") && i + 1 < L.length && /^\|?\s*:?-{2,}/.test(L[i + 1].trim())) { + const head = cells(t); i += 2; const rows = []; + while (i < L.length && L[i].trim().startsWith("|")) rows.push(cells(L[i++])); + out.push(`
    ${head.map((c) => ``).join("")}${rows.map((r) => `${r.map((c) => ``).join("")}`).join("")}
    ${inl(c)}
    ${inl(c)}
    `); + continue; + } + if (t.startsWith(">")) { + const q = []; while (i < L.length && L[i].trim().startsWith(">")) q.push(L[i++].trim().replace(/^>\s?/, "")); + const c = (q[0] || "").match(/^\[!(NOTE|INFO|TIP|WARNING|CAUTION|IMPORTANT)\]\s*(.*)$/i); + if (c) { + const k = {info: "note", caution: "warning"}[c[1].toLowerCase()] || c[1].toLowerCase(); + q[0] = c[2]; + out.push(`
    ${CALL[k]}
    ${pageMd(q.join("\n"), id, rev)}
    `); + } else out.push(`
    ${pageMd(q.join("\n"), id, rev)}
    `); + continue; + } + if (/^([-*+•]|\d+[.)])\s+/.test(t)) { + const ordered = /^\d/.test(t); const items = []; + while (i < L.length && /^\s*([-*+•]|\d+[.)])\s+/.test(L[i])) { + let it = L[i++].trim().replace(/^([-*+•]|\d+[.)])\s+/, ""); + const task = it.match(/^\[( |x|X)\]\s+(.*)$/); + items.push(task ? `
  • ${task[1] === " " ? "☐" : "☑"} ${inl(task[2])}
  • ` : `
  • ${inl(it)}
  • `); + } + out.push(ordered ? `
      ${items.join("")}
    ` : ``); continue; + } + const para = []; + while (i < L.length && L[i].trim() && !/^(#{1,6}\s|```|>|\||([-*+•]|\d+[.)])\s)/.test(L[i].trim())) para.push(L[i++].trim()); + if (!para.length) { para.push(t); i++; } + out.push(`

    ${para.map(inl).join("
    ")}

    `); + } + return out.join(""); +} + function renderAsk() { const a = S.approvals.find((x) => x.bot === S.open); if (!a) { $("ask").innerHTML = ""; return; } diff --git a/src/app.rs b/src/app.rs index 024340a..0770bfb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,7 @@ mod menu_ui; mod vault_ui; mod markdown_ui; mod mirror_ui; +mod pages_ui; // Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025), // clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523): @@ -159,6 +160,8 @@ pub struct App { cu: computers_ui::ComputerUi, vault: vault_ui::VaultUi, md: markdown_ui::MdState, + /// The page open large (頁面): its id, and an older revision when looking at one. + page_view: Option<(u64, Option)>, /// Files and pictures waiting to go with the next message. att: attach_ui::AttachUi, /// 訊息管道 (Slack / Discord / Telegram). @@ -263,6 +266,7 @@ impl App { cu: Default::default(), vault: Default::default(), md: Default::default(), + page_view: None, att: Default::default(), ch: Default::default(), share_rx: None, @@ -318,6 +322,24 @@ impl App { // Optional view for checking the dialogs in a picture. match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { Ok("chat") => app.state.selected = 1, + // 頁面: a tracking page in the chat (pagecard), and opened (page). + Ok(v @ ("pagecard" | "page")) => { + app.state.selected = 1; + app.approvals.clear(); + app.streams.remove(&5); + let (pages, next, id) = crate::pages::demo(crate::model::now_ms()); + app.state.pages = pages; + app.state.next_page_id = next; + if let Some(b) = app.state.bot_mut(1) { + b.messages.push(Message::user("幫我追蹤一下台灣 FSD 狀況")); + let mut m = Message::assistant("交通部今天對台灣 FSD 有新的官方說法,但你 12 月交車時多半還是不能用,現在先別花錢。\n\nNOWnews 今天專訪交通部監理司專門委員。他說 7 月和 9 月已經開過兩次專家審查會議,交通部希望年底前完成相關審查,但不保證年底開放,而且路測計畫還沒核定。來源和細節都更新在[台灣 Tesla FSD 追蹤](page:1)。", "Master · Custom · qwen3.8-flash-next"); + m.tool_calls.push(crate::model::ToolCall { id: "pg1".into(), name: "write_page".into(), args: serde_json::json!({"id": id, "title": "台灣 Tesla FSD 追蹤"}).to_string(), status: crate::model::ToolStatus::Done }); + b.messages.push(m); + } + if v == "page" { + app.page_view = Some((id, None)); + } + } Ok("settings") => { app.show_settings = true; app.settings_tab = SettingsTab::Providers; @@ -889,6 +911,18 @@ impl App { fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) { let out = appctl::apply(&mut self.state, bot_id, &call.name, &call.args); + // write_page: remember which page the call made, so its card shows under the reply. + if let Some(pid) = out.page { + if let Some(c) = self.state.bot_mut(bot_id).and_then(|b| b.messages.iter_mut().rev().take(12).flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call.call_id)) { + let mut a: serde_json::Value = serde_json::from_str(&c.args).unwrap_or(serde_json::json!({})); + a["id"] = serde_json::json!(pid); + // The card shows the page itself; the long content needn't stay twice in the chat. + if let Some(o) = a.as_object_mut() { + o.remove("content"); + } + c.args = a.to_string(); + } + } if out.theme_changed { self.apply_theme(ctx); } @@ -3306,6 +3340,7 @@ impl eframe::App for App { let ctx = &ui.ctx().clone(); self.handle_screenshot(ctx); self.md_viewer(ctx); + self.page_viewer(ctx); self.refresh_presence(ctx); self.check_for_newer_build(); if std::mem::take(&mut self.relaunch_requested) { @@ -3605,6 +3640,12 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu s.push_str("\n\n你目前的例行任務:\n"); s.push_str(&b.routines.iter().map(appctl::routine_line).collect::>().join("\n")); } + // 頁面: what this bot already keeps, so a routine or a follow-up updates the same page. + let mine: Vec = st.pages.iter().filter(|p| p.bot == b.id).map(|p| format!("#{} 「{}」(第 {} 版,更新於 {})", p.id, p.title, p.revision, crate::pages::when(p.updated))).collect(); + if !mine.is_empty() { + s.push_str("\n\n你做過的頁面(更新時用 write_page 帶 id,先 read_page 看現在的內容):\n"); + s.push_str(&mine.join("\n")); + } if !computers.is_empty() { let list: Vec = computers .iter() @@ -3656,6 +3697,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu s.push_str("\n\n"); s.push_str(b.instructions.trim()); } + s.push_str("\n\n使用者要你追蹤、整理、比較、研究一件之後還會回來看的事(例如「幫我追蹤台灣 FSD 狀況」),就用 write_page 做成一頁:先寫結論和現況,再寫細節和來源(附日期),適合時用表格、```stats、```steps、圖表。回答時簡短說重點,並用 [頁面標題](page:ID) 連到它。要定期追蹤時,再用 create_routine 設例行任務,每次執行:read_page、查新消息,有實質進展才用 write_page 更新(note 寫改了什麼)並在聊天裡說;沒有新消息就簡短回答「沒有新消息」,不要更新頁面。"); s.push_str("\n\n做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。"); s.push_str("\n\n"); s.push_str(&crate::i18n::reply_instruction(&st.language)); diff --git a/src/app/chat_ui.rs b/src/app/chat_ui.rs index c37e043..0e412df 100644 --- a/src/app/chat_ui.rs +++ b/src/app/chat_ui.rs @@ -126,7 +126,7 @@ impl App { if m.role == Role::Tool { return; } - let has_body = !m.text.trim().is_empty() || !m.attachments.is_empty() || m.tool_calls.iter().any(|c| c.name == "show_card"); + let has_body = !m.text.trim().is_empty() || !m.attachments.is_empty() || m.tool_calls.iter().any(|c| c.name == "show_card") || !crate::pages::ids_in(&m.tool_calls).is_empty(); let steps_open = self.steps_open.contains(&(bot.id, idx)); if m.role == Role::Assistant && !has_body && !m.summary && !steps_open { return; @@ -380,6 +380,11 @@ impl App { ui.add_space(6.0); card(ui, &c.args, p); } + // 頁面 written in this reply: the card (the page as it is now). + for id in crate::pages::ids_in(&m.tool_calls) { + ui.add_space(8.0); + self.page_card(ui, id); + } for a in m.attachments.clone() { ui.add_space(6.0); self.file_card(ui, &a); diff --git a/src/app/markdown_ui.rs b/src/app/markdown_ui.rs index d96ecaa..64bb5e3 100644 --- a/src/app/markdown_ui.rs +++ b/src/app/markdown_ui.rs @@ -372,6 +372,28 @@ fn key_of(s: &str, dark: bool) -> u64 { h.finish() } +/// `> [!NOTE] …` quotes: the kind, and the quote without the marker. +fn callout_of(inner: &[Block]) -> Option<(&'static str, Vec)> { + let Some(Block::Para(spans)) = inner.first() else { return None }; + let first = spans.first()?; + // "[!NOTE]" may be parsed as text, or with the rest of the line after it. + let (kind, rest) = crate::pages::callout(&first.text)?; + let mut spans = spans.clone(); + spans[0].text = rest.trim_start().to_string(); + if spans[0].text.is_empty() { + spans.remove(0); + if let Some(s) = spans.first_mut() { + s.text = s.text.trim_start().to_string(); + } + } + let mut out = vec![]; + if !spans.is_empty() { + out.push(Block::Para(spans)); + } + out.extend(inner[1..].iter().cloned()); + Some((kind, out)) +} + impl App { /// A message's Markdown. `streaming`: the reply is still coming (diagrams wait for the end). pub(super) fn markdown(&mut self, ui: &mut egui::Ui, text: &str, col: Color32, streaming: bool) { @@ -404,6 +426,10 @@ impl App { ui.painter().hline(r.x_range(), r.top(), Stroke::new(1.0, p.border)); ui.add_space(4.0); } + Block::Quote(inner) if callout_of(inner).is_some() => { + let (kind, inner) = callout_of(inner).unwrap(); + self.md_callout(ui, kind, &inner, streaming, depth); + } Block::Quote(inner) => { let top = ui.cursor().top(); Frame::new().inner_margin(Margin { left: 14, right: 0, top: 0, bottom: 0 }).show(ui, |ui| { @@ -463,6 +489,8 @@ impl App { Block::Table { aligns, head, rows } => self.md_table(ui, aligns, head, rows, col), Block::Math(t) => self.md_code(ui, "math", t, col), Block::Code { lang, text } if lang == "mermaid" => self.md_mermaid(ui, text, col, streaming), + Block::Code { lang, text } if lang == "stats" && !crate::pages::stats(text).is_empty() => self.md_stats(ui, text), + Block::Code { lang, text } if lang == "steps" && !crate::pages::steps(text).is_empty() => self.md_steps(ui, text), Block::Code { lang, text } => self.md_code(ui, lang, text, col), } } @@ -549,14 +577,127 @@ impl App { if let Some((_, url)) = ranges.iter().find(|(r, _)| r.contains(&idx)) { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); let url = url.clone(); - let resp = resp.on_hover_text(&url); - if resp.clicked() { - let _ = crate::preview::system_open(&url).spawn(); + if let Some(pid) = crate::pages::link_id(&url) { + let resp = resp.on_hover_text(t("打開頁面")); + if resp.clicked() { + self.page_view = Some((pid, None)); + } + } else { + let resp = resp.on_hover_text(&url); + if resp.clicked() { + let _ = crate::preview::system_open(&url).spawn(); + } } } } } + /// ```stats: number tiles in rows (label above, the value large, a note below). + fn md_stats(&mut self, ui: &mut egui::Ui, src: &str) { + let p = self.pal; + let items = crate::pages::stats(src); + let gap = 8.0; + let w = ui.available_width(); + let per_row = ((w + gap) / (150.0 + gap)).floor().clamp(1.0, 4.0) as usize; + let tile_w = (w - gap * (per_row as f32 - 1.0)) / per_row as f32; + for row in items.chunks(per_row) { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = gap; + for (label, value, note) in row { + Frame::new().fill(p.hover).corner_radius(10).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| { + ui.set_width(tile_w - 24.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.add(egui::Label::new(RichText::new(label).size(12.0).color(p.text2)).wrap()); + ui.add(egui::Label::new(RichText::new(value).size(20.0).strong().color(p.text)).wrap()); + if !note.is_empty() { + ui.add(egui::Label::new(RichText::new(note).size(11.5).color(p.text3)).wrap()); + } + }); + }); + } + }); + ui.add_space(gap); + } + } + + /// ```steps: a vertical progress line — done ✓, now (a ring in the accent colour), to do. + fn md_steps(&mut self, ui: &mut egui::Ui, src: &str) { + let p = self.pal; + let items = crate::pages::steps(src); + let n = items.len(); + let green = Color32::from_rgb(0x2e, 0xa0, 0x43); + for (i, (st, name, note)) in items.iter().enumerate() { + let top = ui.cursor().top(); + let x = ui.cursor().left() + 9.0; + Frame::new().inner_margin(Margin { left: 28, right: 0, top: 0, bottom: if i + 1 < n { 12 } else { 0 } }).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + let c = match st { + crate::pages::Step::Todo => p.text3, + _ => p.text, + }; + let mut t = RichText::new(name).size(TRANSCRIPT_FONT).color(c); + if *st == crate::pages::Step::Now { + t = t.strong(); + } + ui.add(egui::Label::new(t).wrap()); + if !note.is_empty() { + ui.add(egui::Label::new(RichText::new(note).size(12.5).color(p.text2)).wrap()); + } + }); + let bottom = ui.cursor().top(); + let cy = top + 10.0; + let painter = ui.painter(); + if i + 1 < n { + let line = if *st == crate::pages::Step::Done { green } else { p.border }; + painter.vline(x, (cy + 9.0)..=(bottom + 1.0), Stroke::new(2.0, line)); + } + let c = egui::pos2(x, cy); + match st { + crate::pages::Step::Done => { + painter.circle_filled(c, 8.0, green); + painter.line_segment([c + vec2(-3.5, 0.0), c + vec2(-1.0, 2.8)], Stroke::new(1.8, Color32::WHITE)); + painter.line_segment([c + vec2(-1.0, 2.8), c + vec2(3.8, -2.6)], Stroke::new(1.8, Color32::WHITE)); + } + crate::pages::Step::Now => { + painter.circle_filled(c, 8.0, p.pane); + painter.circle_stroke(c, 7.0, Stroke::new(2.5, p.accent)); + painter.circle_filled(c, 3.0, p.accent); + } + crate::pages::Step::Todo => { + painter.circle_filled(c, 8.0, p.pane); + painter.circle_stroke(c, 7.0, Stroke::new(1.5, p.text3)); + } + } + } + } + + /// `> [!WARNING] …`: a tinted box with a coloured edge and a small title. + fn md_callout(&mut self, ui: &mut egui::Ui, kind: &str, inner: &[Block], streaming: bool, depth: usize) { + let p = self.pal; + let (edge, title) = match kind { + "warning" => (Color32::from_rgb(0xe0, 0x8a, 0x00), "注意"), + "important" => (Color32::from_rgb(0xd0, 0x3b, 0x3b), "重要"), + "tip" => (Color32::from_rgb(0x2e, 0xa0, 0x43), "提示"), + _ => (p.accent, "說明"), + }; + let top = ui.cursor().top(); + Frame::new().fill(edge.gamma_multiply(0.10)).corner_radius(8).inner_margin(Margin { left: 16, right: 12, top: 10, bottom: 10 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 0.0; + ui.label(RichText::new(title).size(12.5).strong().color(edge)); + ui.add_space(4.0); + for (i, b) in inner.iter().enumerate() { + if i > 0 { + ui.add_space(6.0); + } + self.md_block(ui, b, p.text, streaming, depth); + } + }); + let r = ui.min_rect(); + ui.painter().vline(r.left() + 1.5, (top + 2.0)..=(ui.cursor().top() - 2.0), Stroke::new(3.0, edge)); + } + fn md_code(&mut self, ui: &mut egui::Ui, lang: &str, text: &str, col: Color32) { let p = self.pal; let dark = p.pane != LIGHT.pane; @@ -794,4 +935,17 @@ mod tests { assert!(hl.iter().any(|(s, _)| s == "let") && hl.iter().any(|(s, _)| s.starts_with("// hi"))); } + #[test] + fn call_outs_and_page_blocks() { + for src in ["> [!WARNING] 現在先別花錢", "> [!WARNING]\n> 現在先別花錢"] { + let b = parse(src); + let Block::Quote(inner) = &b[0] else { panic!("{b:?}") }; + let (kind, rest) = callout_of(inner).unwrap_or_else(|| panic!("{src}: {inner:?}")); + assert_eq!(kind, "warning"); + let text: String = rest.iter().map(|b| match b { Block::Para(s) => s.iter().map(|x| x.text.clone()).collect::(), _ => String::new() }).collect(); + assert!(text.contains("現在先別花錢") && !text.contains("[!"), "{src}: {text:?}"); + } + assert!(callout_of(match &parse("> 一般的引言")[0] { Block::Quote(i) => i, _ => panic!() }).is_none()); + assert!(matches!(&parse("```stats\n狀態 | 審查中\n```")[0], Block::Code { lang, .. } if lang == "stats")); + } } diff --git a/src/app/pages_ui.rs b/src/app/pages_ui.rs new file mode 100644 index 0000000..aeb932d --- /dev/null +++ b/src/app/pages_ui.rs @@ -0,0 +1,232 @@ +// 頁面 in the window: the card under a reply (like the original's chat card — the title, a few +// lines of the page fading out, then a footer row with the page icon, its name and 「頁面」), the +// page itself in a large dialog (the whole page drawn with the chat's Markdown, the version picker +// and 存成檔案), and the list under 資料庫 in the right panel. + +use super::*; + +const CARD_W: f32 = 520.0; + +impl App { + /// The card for page `id` under a reply. + pub(super) fn page_card(&mut self, ui: &mut egui::Ui, id: u64) { + let p = self.pal; + let Some(page) = self.state.pages.iter().find(|x| x.id == id).cloned() else { + ui.label(RichText::new(t("(這個頁面已刪除)")).size(12.5).color(p.text3)); + return; + }; + let now = crate::model::now_ms(); + let w = ui.available_width().min(CARD_W); + let resp = Frame::new().fill(p.pane).stroke(Stroke::new(1.0, p.border)).corner_radius(16).show(ui, |ui| { + ui.set_width(w); + ui.spacing_mut().item_spacing.y = 0.0; + // The page's beginning: the title, then a few lines fading out. + Frame::new().inner_margin(Margin { left: 18, right: 18, top: 16, bottom: 12 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(&page.title).size(17.0).strong().color(p.text)).wrap()); + ui.add_space(8.0); + let lines = crate::pages::preview(&page, 4); + let n = lines.len().max(1) as f32; + for (i, l) in lines.iter().enumerate() { + let fade = 1.0 - (i as f32 / n) * 0.65; + let c = if i == 0 && !page.summary.is_empty() { p.text } else { p.text2 }; + ui.add(egui::Label::new(RichText::new(l).size(13.5).color(c.gamma_multiply(fade))).truncate()); + ui.add_space(4.0); + } + }); + let r = ui.min_rect(); + ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, p.border)); + // The footer row: page icon, name, 「頁面 · 第 N 版 · 更新」. + Frame::new().inner_margin(Margin::symmetric(18, 12)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (ir, _) = ui.allocate_exact_size(vec2(30.0, 36.0), egui::Sense::hover()); + page_glyph(ui.painter(), ir, p); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.add(egui::Label::new(RichText::new(&page.title).size(14.0).color(p.text)).truncate()); + let mut meta = format!("{} · {}", t("頁面"), crate::pages::ago(page.updated, now)); + if page.revision > 1 { + meta = format!("{meta} · {}", t("已更新")); + } + ui.label(RichText::new(meta).size(12.0).color(p.text3)); + }); + }); + if page.revision > 1 && !page.note.is_empty() { + ui.add_space(6.0); + ui.add(egui::Label::new(RichText::new(format!("{}:{}", t("最近更新"), page.note)).size(12.0).color(p.text2)).wrap()); + } + }); + }); + let r = ui.interact(resp.response.rect, egui::Id::unique(("page-card", id, self.msg_index)), egui::Sense::click()); + if r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if r.on_hover_text(t("打開頁面")).clicked() { + self.page_view = Some((id, None)); + } + } + + /// The page, large: everything drawn like the chat, with the version picker and the footer. + pub(super) fn page_viewer(&mut self, ctx: &egui::Context) { + let Some((id, rev)) = self.page_view else { return }; + let p = self.pal; + let Some(cur) = self.state.pages.iter().find(|x| x.id == id).cloned() else { + self.page_view = None; + self.notice = Some(t("這個頁面已經刪除了")); + return; + }; + let shown = rev.and_then(|r| crate::pages::version_of(&cur, r)).unwrap_or_else(|| cur.clone()); + let old = shown.revision != cur.revision; + let bot_name = self.state.bot(cur.bot).map(|b| b.name.clone()).unwrap_or_default(); + let w = (ctx.content_rect().width() - 80.0).clamp(360.0, 860.0); + let h = (dialog_max_height(ctx) - 150.0).max(240.0); + let mut keep = true; + let mut delete = false; + let mut pick: Option> = None; + let close = dialog(ctx, "page-view", "頁面", w, true, p, |ui| { + dialog_body(ui, ("page-body", id), h, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.add(egui::Label::new(RichText::new(&shown.title).size(26.0).strong().color(p.text)).wrap()); + ui.add_space(6.0); + let mut meta = format!("{} · {} {}", bot_name, t("更新於"), crate::pages::when(shown.updated)); + if cur.revision > 1 || old { + meta = format!("{meta} · {} {}", t("第"), shown.revision) + &t(" 版"); + } + ui.label(RichText::new(meta).size(12.5).color(p.text3)); + if old { + ui.add_space(8.0); + Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(t("你在看舊的版本")).size(12.5).color(p.text2)); + if ui.add(secondary_button("回到最新版")).clicked() { + pick = Some(None); + } + }); + }); + } else if !shown.summary.is_empty() { + ui.add_space(12.0); + ui.add(egui::Label::new(RichText::new(&shown.summary).size(15.0).color(p.text)).wrap()); + } + if !shown.note.is_empty() && shown.revision > 1 { + ui.add_space(6.0); + ui.add(egui::Label::new(RichText::new(format!("{}:{}", t("這一版更新了"), shown.note)).size(12.5).color(p.text2)).wrap()); + } + ui.add_space(16.0); + let body = shown.body.clone(); + self.markdown(ui, &body, p.text, false); + ui.add_space(8.0); + }); + dialog_footer(ui, p, |ui| { + if !cur.history.is_empty() { + let label = if old { format!("{} {}", t("第"), shown.revision) + &t(" 版") } else { t("版本紀錄") }; + egui::ComboBox::from_id_salt(("page-versions", id)).selected_text(label).width(150.0).show_ui(ui, |ui| { + if ui.selectable_label(!old, format!("{} {} {}({})", t("第"), cur.revision, t("版"), t("最新"))).clicked() { + pick = Some(None); + } + for v in cur.history.iter().rev() { + let text = format!("{} {} {} · {}", t("第"), v.revision, t("版"), crate::pages::when(v.at)); + if ui.selectable_label(rev == Some(v.revision), text).clicked() { + pick = Some(Some(v.revision)); + } + } + }); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(primary_button("完成", p)).clicked() { + keep = false; + } + if ui.add(secondary_button("存成檔案")).on_hover_text(t("存成 Markdown 到「下載」")).clicked() { + let dir = dirs::download_dir().unwrap_or_else(std::env::temp_dir); + let name: String = shown.title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).collect(); + let path = dir.join(format!("{name}.md")); + let text = format!("# {}\n\n{}\n", shown.title, shown.body); + match std::fs::write(&path, text) { + Ok(()) => { + crate::preview::reveal(&path); + self.notice = Some(format!("{}{}", t("已存到 "), path.display())); + } + Err(e) => self.notice = Some(format!("{}{e}", t("存不了:"))), + } + } + if ui.add(secondary_button("複製")).on_hover_text(t("複製成 Markdown")).clicked() { + ui.ctx().copy_text(format!("# {}\n\n{}", shown.title, shown.body)); + self.notice = Some(t("已複製")); + } + if !old && ui.add(egui::Button::new(RichText::new(t("刪除")).color(p.danger)).frame(false)).clicked() { + delete = true; + } + }); + }); + }); + if let Some(v) = pick { + self.page_view = Some((id, v)); + return; + } + if delete { + self.state.pages.retain(|x| x.id != id); + self.page_view = None; + self.notice = Some(format!("{}「{}」", t("已刪除頁面"), cur.title)); + self.mark(); + return; + } + if close || !keep { + self.page_view = None; + } + } + + /// 資料庫 › 頁面: this bot's pages, newest change first. Whether there were any. + pub(super) fn pages_library(&mut self, ui: &mut egui::Ui, bot: &Bot) -> bool { + let p = self.pal; + let mut mine: Vec = self.state.pages.iter().filter(|x| x.bot == bot.id).cloned().collect(); + if mine.is_empty() { + return false; + } + mine.sort_by_key(|x| std::cmp::Reverse(x.updated)); + let now = crate::model::now_ms(); + let mut open = None; + super::panel_ui::panel_title(ui, "頁面", p); + super::panel_ui::panel_card(ui, p, |ui| { + for pg in &mine { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 9, p.hover); + } + let ir = egui::Rect::from_center_size(rect.left_center() + vec2(20.0, 0.0), vec2(18.0, 24.0)); + page_glyph(ui.painter(), ir, p); + let x = rect.left() + 38.0; + let title = super::panel_ui::shorten_size(ui, &pg.title, rect.right() - x - 8.0, 13.0); + ui.painter().text(egui::pos2(x, rect.top() + 6.0), egui::Align2::LEFT_TOP, title, egui::FontId::proportional(13.0), p.text); + ui.painter().text(egui::pos2(x, rect.top() + 25.0), egui::Align2::LEFT_TOP, format!("{} · {}", t("頁面"), crate::pages::ago(pg.updated, now)), egui::FontId::proportional(11.5), p.text3); + if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + open = Some(pg.id); + } + } + }); + if let Some(id) = open { + self.page_view = Some((id, None)); + } + true + } +} + +/// The page icon: a sheet with a folded corner, two text lines and four coloured bars (like the +/// original's page glyph). +pub(super) fn page_glyph(painter: &egui::Painter, r: egui::Rect, p: Palette) { + let w = r.width().min(r.height() * 0.8); + let rect = egui::Rect::from_center_size(r.center(), vec2(w, w * 1.25)); + painter.rect_filled(rect, 4, p.hover); + painter.rect_stroke(rect, 4, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + let u = rect.width() / 10.0; + let l = rect.left() + u * 2.0; + painter.hline(l..=(rect.left() + u * 6.0), rect.top() + u * 2.6, Stroke::new(u * 0.9, p.text3)); + painter.hline(l..=(rect.left() + u * 4.6), rect.top() + u * 4.4, Stroke::new(u * 0.9, p.text3)); + let colors = [Color32::from_rgb(0x34, 0xa8, 0x53), Color32::from_rgb(0xfb, 0xbc, 0x05), Color32::from_rgb(0xea, 0x43, 0x35), Color32::from_rgb(0x42, 0x85, 0xf4)]; + let base = rect.bottom() - u * 2.0; + for (i, c) in colors.iter().enumerate() { + let h = u * (2.0 + i as f32 * 1.1); + let x = l + i as f32 * u * 1.7; + painter.rect_filled(egui::Rect::from_min_max(egui::pos2(x, base - h), egui::pos2(x + u * 1.1, base)), 1, *c); + } +} diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs index a21902b..4e81b11 100644 --- a/src/app/panel_ui.rs +++ b/src/app/panel_ui.rs @@ -312,6 +312,11 @@ impl App { } } files.sort_by(|a, b| b.2.cmp(&a.2)); + // 頁面 the bot made come first. + let has_pages = self.pages_library(ui, bot); + if files.is_empty() && has_pages { + return; + } if files.is_empty() { ui.vertical_centered(|ui| { ui.add_space(20.0); @@ -368,7 +373,7 @@ fn round_button(ui: &mut egui::Ui, p: Palette, size: f32) -> egui::Response { r.on_hover_cursor(egui::CursorIcon::PointingHand) } -fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) { +pub(super) fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) { ui.horizontal(|ui| { ui.add_space(12.0); ui.label(RichText::new(t(title)).size(13.0).color(p.text.gamma_multiply(0.66))); @@ -377,7 +382,7 @@ fn panel_title(ui: &mut egui::Ui, title: &str, p: Palette) { } /// The original's card: radius 12, a hairline border, 4 px inside. -fn panel_card(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) { +pub(super) fn panel_card(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) { Frame::new().fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(12).inner_margin(Margin::same(4)).show(ui, |ui| { ui.set_width(ui.available_width()); ui.spacing_mut().item_spacing.y = 0.0; @@ -468,7 +473,7 @@ fn shorten(ui: &egui::Ui, s: &str, max_w: f32) -> String { } /// Cut text to fit, keeping the end of a file name ("orders_….xlsx" like the original). -fn shorten_size(ui: &egui::Ui, s: &str, max_w: f32, size: f32) -> String { +pub(super) fn shorten_size(ui: &egui::Ui, s: &str, max_w: f32, size: f32) -> String { if text_width(ui, s, size) <= max_w { return s.to_string(); } diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 3c60e0c..bd58e72 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -218,6 +218,9 @@ impl App { } self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h); } + for pg in &self.state.pages { + (pg.id, pg.revision, pg.updated, &pg.title).hash(&mut h); + } h.finish() } @@ -244,11 +247,12 @@ impl App { "unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned, })); let start = b.messages.len().saturating_sub(remote::PHONE_TAIL); - view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names)).collect()); + view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names, &self.state.pages)).collect()); if let Some(a) = self.approvals.get(&b.id) { view.approvals.push(approval_json(b, a)); } } + view.pages = self.state.pages.clone(); remote::hub().publish(view); } diff --git a/src/appctl.rs b/src/appctl.rs index 683bf17..be81849 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -17,7 +17,7 @@ pub const NAMES: [&str; 20] = [ ]; pub fn is_app_tool(name: &str) -> bool { - NAMES.contains(&name) + NAMES.contains(&name) || crate::pages::NAMES.contains(&name) } pub fn label(name: &str) -> &'static str { @@ -42,6 +42,7 @@ pub fn label(name: &str) -> &'static str { "move_to_group" => "移到群組", "rename_group" => "重新命名群組", "delete_group" => "刪除群組", + n if crate::pages::NAMES.contains(&n) => crate::pages::label(n), _ => "App 操作", } } @@ -109,6 +110,9 @@ pub fn specs() -> Vec { "parameters": {"type": "object", "properties": { "theme": {"type": "string", "enum": ["light", "dark", "system"]}, "accent": {"type": "string", "enum": ["bot_color", "black"]}, "timezone": {"type": "string"}}}}), ] + .into_iter() + .chain(crate::pages::specs()) + .collect() } /// One line for the chat row. @@ -124,6 +128,7 @@ pub fn describe(name: &str, args: &str) -> String { "delete_routine" | "run_routine_now" => format!("#{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), "message_bot" => format!("給 {}:{}", s("bot"), s("text").chars().take(40).collect::()), "create_bot" | "update_bot" => s("name").to_string() + &s("bot"), + n if crate::pages::NAMES.contains(&n) => crate::pages::describe(n, &v), _ => String::new(), } } @@ -146,14 +151,16 @@ pub struct Outcome { /// Bots to wake with a new turn (message_bot, run_routine_now). pub wake: Vec<(u64, Message)>, pub theme_changed: bool, + /// write_page: the page made or changed (its card goes under the reply). + pub page: Option, } fn ok(text: String) -> Outcome { - Outcome { text, ok: true, wake: vec![], theme_changed: false } + Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None } } fn err(text: String) -> Outcome { - Outcome { text, ok: false, wake: vec![], theme_changed: false } + Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None } } fn find_bot<'a>(state: &'a State, name: &str) -> Option<&'a Bot> { @@ -190,6 +197,15 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); let now = routines::now(); match name { + "write_page" => match crate::pages::write(&mut state.pages, &mut state.next_page_id, bot_id, &v, crate::model::now_ms()) { + Ok(w) => Outcome { text: w.text, ok: true, wake: vec![], theme_changed: false, page: Some(w.id) }, + Err(e) => err(e), + }, + "read_page" => match crate::pages::read(&state.pages, bot_id, &v) { + Ok(t) => ok(t), + Err(e) => err(e), + }, + "list_pages" => ok(crate::pages::list(&state.pages, bot_id)), "list_routines" => { let Some(b) = state.bot(bot_id) else { return err("找不到這個 Bot".into()) }; if b.routines.is_empty() { @@ -255,7 +271,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome let r = &mut b.routines[idx]; r.last_run = Some(now); let msg = wake_message(r); - Outcome { text: format!("已開始執行「{}」", r.title), ok: true, wake: vec![(owner, msg)], theme_changed: false } + Outcome { text: format!("已開始執行「{}」", r.title), ok: true, wake: vec![(owner, msg)], theme_changed: false, page: None } } _ => { let r = &mut b.routines[idx]; @@ -322,7 +338,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome if !missing.is_empty() { text.push_str(&format!("找不到:{}。", missing.join("、"))); } - Outcome { text, ok: missing.is_empty() || !moved.is_empty(), wake: vec![], theme_changed: false } + Outcome { text, ok: missing.is_empty() || !moved.is_empty(), wake: vec![], theme_changed: false, page: None } } "rename_group" => { let (old, new) = (s("group").unwrap_or_default(), s("new_name").unwrap_or_default().trim().to_string()); @@ -524,7 +540,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome let text = s("text").unwrap_or_default(); let msg = Message { wake: true, ..Message::user(&format!("[來自 Bot]「{from}」:{text}")) }; let name = state.bot(target).map(|b| b.name.clone()).unwrap_or_default(); - Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false } + Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None } } "list_computers" => { let mut lines = vec![ @@ -535,7 +551,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome ok(lines.join("\n")) } "update_settings" => { - let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false }; + let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None }; let mut changed = vec![]; if let Some(t) = s("theme") { state.theme = match t.as_str() { diff --git a/src/main.rs b/src/main.rs index 2797ff6..2d2619e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod desktop; mod diagram; mod platform; mod mic; +mod pages; #[cfg(target_os = "linux")] mod x11; #[cfg(target_os = "linux")] diff --git a/src/model.rs b/src/model.rs index 05175e9..a5030f1 100644 --- a/src/model.rs +++ b/src/model.rs @@ -323,6 +323,11 @@ pub struct State { /// 技能 every bot can load. #[serde(default)] pub skills: Vec, + /// 頁面 the bots made and keep up to date. + #[serde(default)] + pub pages: Vec, + #[serde(default = "first_routine_id")] + pub next_page_id: u64, /// 手機與帳號: phone access, the relay account and backups. #[serde(default)] pub remote: crate::remote::Settings, @@ -507,6 +512,8 @@ impl State { next_channel_id: 1, mcp_servers: vec![], skills: vec![], + pages: vec![], + next_page_id: 1, remote: Default::default(), language: auto_lang(), folders: vec![], diff --git a/src/pages.rs b/src/pages.rs new file mode 100644 index 0000000..59a0c75 --- /dev/null +++ b/src/pages.rs @@ -0,0 +1,419 @@ +// 頁面: 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); + } +} diff --git a/src/remote.rs b/src/remote.rs index d830d1e..f032fd3 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -131,6 +131,8 @@ pub struct View { pub bots: Vec, pub approvals: Vec, pub messages: HashMap>, + /// 頁面, for the phone's page view. + pub pages: Vec, pub version: u64, } @@ -251,7 +253,7 @@ fn tool_detail(name: &str, args: &str) -> String { } /// One message as the phone shows it (tool results are folded away like in the window). -pub fn message_json(m: &crate::model::Message, names: &HashMap) -> Option { +pub fn message_json(m: &crate::model::Message, names: &HashMap, pages: &[crate::pages::Page]) -> Option { use crate::model::{Role, ToolStatus}; if m.role == Role::Tool { return None; @@ -267,8 +269,11 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap) -> ToolStatus::Denied => "denied", ToolStatus::Failed => "failed", }; + let page = (c.name == "write_page").then(|| serde_json::from_str::(&c.args).ok().and_then(|a| a["id"].as_u64())).flatten().and_then(|id| pages.iter().find(|p| p.id == id)); if c.name == "show_card" { json!({"card": serde_json::from_str::(&c.args).unwrap_or(Value::Null), "status": st}) + } else if let Some(p) = page { + json!({"page": crate::pages::card_json(p, crate::model::now_ms()), "status": st}) } else { json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st}) } @@ -340,6 +345,32 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { "role": role, })) } + // 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG. + ("GET", ["api", "pages", id]) => { + let id: u64 = id.parse().unwrap_or(0); + let v = hub().view.lock().unwrap(); + let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") }; + let shown = req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()); + let bot = v.bots.iter().find(|b| b["id"].as_u64() == Some(cur.bot)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string(); + let mut out = crate::pages::page_json(&shown, &bot, crate::model::now_ms()); + out["versions"] = crate::pages::page_json(cur, &bot, 0)["versions"].clone(); + out["latest"] = json!(cur.revision); + Resp::json(&out) + } + ("GET", ["api", "pages", id, "chart", n]) => { + let id: u64 = id.parse().unwrap_or(0); + let n: usize = n.parse().unwrap_or(0); + let body = { + let v = hub().view.lock().unwrap(); + let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") }; + req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()).body + }; + let Some(src) = crate::pages::mermaid_blocks(&body).into_iter().nth(n) else { return Resp::error(404, "找不到這張圖") }; + match crate::diagram::svg(&src, req.query.get("dark").map(|d| d == "1").unwrap_or(false)) { + Ok(svg) => Resp::bytes("image/svg+xml", svg.into_bytes()), + Err(e) => Resp::error(422, &e), + } + } ("GET", ["api", "bots", id]) => { let id: u64 = id.parse().unwrap_or(0); let v = hub().view.lock().unwrap(); @@ -450,7 +481,7 @@ pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool { // 大腦: the owner's other AnyBots showing this one's bots. "mirror" => sync, "owner" | "admin" => !device && !sync, - "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), + "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), _ => false, } }