頁面: pages a bot makes and keeps up to date (like the original's tracking pages)

- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
  the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
  [標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
  listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
  > [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
  AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
  there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
This commit is contained in:
王性驊 2026-11-05 01:41:41 +08:00
parent 6d6461e4f3
commit 2bc573f095
12 changed files with 1121 additions and 18 deletions

View File

@ -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}
</div>
</section>
<section class="view" id="v-page">
<header>
<button class="icon-btn" id="b-back3"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
<h1 id="page-name" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">頁面</h1>
<select id="pver" style="display:none"></select>
</header>
<div class="scroll" id="pscroll"><div class="page" id="page"></div></div>
</section>
<!-- screens -->
<section class="view" id="v-screen">
<header>
@ -405,6 +469,7 @@ function md(text) {
let h = esc(part)
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `${GLYPH(14)}<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
const out = []; let list = null;
@ -431,7 +496,7 @@ function renderMsgs() {
return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`;
}
if (m.summary) return `<div class="m bot"><div class="via">(較早的對話已整理成摘要)</div></div>`;
const tools = (m.tools || []).map((t) => t.card ? card(t.card) : `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`).join("");
const tools = (m.tools || []).map((t) => t.page ? pageCard(t.page) : t.card ? card(t.card) : `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`).join("");
const last = i === S.msgs.length - 1;
const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : "");
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
@ -448,6 +513,128 @@ function card(c) {
const rows = (c.rows || []).map((r) => `<div class="r"><span>${esc(r.label)}</span><span>${esc(r.value)}</span></div>`).join("");
return `<div class="card"><div class="t">${esc(c.title)}</div>${c.subtitle ? `<div class="s">${esc(c.subtitle)}</div>` : ""}${rows}${c.link ? `<div class="r"><a href="${esc(c.link)}" target="_blank" rel="noopener">來源</a><span></span></div>` : ""}</div>`;
}
// ---------- 頁面 ----------
const GLYPH = (h = 34) => `<svg class="pglyph" width="${Math.round(h * .8)}" height="${h}" viewBox="0 0 24 30" style="vertical-align:-3px"><rect x=".5" y=".5" width="23" height="29" rx="4" fill="var(--raise)" stroke="var(--line)"/><path d="M5 7h9M5 11h6" stroke="var(--text3)" stroke-width="2" stroke-linecap="round"/><rect x="5" y="20" width="2.6" height="5" rx=".6" fill="#34a853"/><rect x="9" y="18" width="2.6" height="7" rx=".6" fill="#fbbc05"/><rect x="13" y="16" width="2.6" height="9" rx=".6" fill="#ea4335"/><rect x="17" y="14" width="2.6" height="11" rx=".6" fill="#4285f4"/></svg>`;
function pageCard(p) {
const lines = (p.preview || []).map((l, i, a) => `<div class="l" style="opacity:${1 - (i / Math.max(a.length, 1)) * .65}">${esc(l)}</div>`).join("");
const meta = `頁面 · ${esc(p.updated)}${p.revision > 1 ? " · 已更新" : ""}`;
return `<div class="pcard" data-page="${p.id}"><div class="top"><div class="t">${esc(p.title)}</div>${lines}</div>${p.revision > 1 && p.note ? `<div class="note">最近更新:${esc(p.note)}</div>` : ""}<div class="foot">${GLYPH()}<div style="min-width:0"><div class="n">${esc(p.title)}</div><span class="m">${meta}</span></div></div></div>`;
}
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 = `<div class="empty">載入中…</div>`;
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 = `<option value="">第 ${v.latest} 版(最新)</option>` + v.versions.map((x) => `<option value="${x.revision}">第 ${x.revision} 版 · ${esc(x.at)}</option>`).join("");
sel.value = old ? String(v.revision) : ""; sel.style.display = "";
} else sel.style.display = "none";
let head = `<h1 class="pt">${esc(v.title)}</h1><div class="meta">${esc(v.bot)} · 更新於 ${esc(v.updated)}${v.latest > 1 ? ` · 第 ${v.revision} 版` : ""}</div>`;
if (old) head += `<div class="old">你在看舊的版本。<a class="plink" data-page="${id}">回到最新版</a></div>`;
else if (v.summary) head += `<p class="sum">${esc(v.summary)}</p>`;
if (v.note && v.revision > 1) head += `<div class="chg">這一版更新了:${esc(v.note)}</div>`;
$("page").innerHTML = head + pageMd(v.body, id, old ? v.revision : 0);
loadCharts();
} catch (e) { $("page").innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
}
$("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 = `<img alt="圖表" src="${URL.createObjectURL(await r.blob())}">`;
} catch (_) { el.innerHTML = `<div class="wait">這張圖畫不出來</div>`; }
}
}
// Inline Markdown: code, bold, italics, strike, links (page:N opens the page).
function inl(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>")
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s((])(https?:\/\/[^\s<))]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
}
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(`<div class="chart" data-src="api/pages/${id}/chart/${chart++}${rev ? "?rev=" + rev : ""}"><div class="wait">圖表載入中…</div></div>`); continue; }
if (lang === "stats") {
const tiles = body.map(cells).filter((c) => c.length >= 2 && c[0]).map((c) => `<div class="stat"><div class="k">${inl(c[0])}</div><div class="v">${inl(c[1])}</div>${c[2] ? `<div class="s">${inl(c[2])}</div>` : ""}</div>`);
if (tiles.length) { out.push(`<div class="stats">${tiles.join("")}</div>`); 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 `<li class="${st}"><span class="dot">${st === "done" ? "✓" : ""}</span><div class="nm">${inl(nm)}</div>${nt ? `<div class="nt">${inl(nt)}</div>` : ""}</li>`;
});
if (items.length) { out.push(`<ul class="steps">${items.join("")}</ul>`); continue; }
}
out.push(`<pre><code>${esc(body.join("\n"))}</code></pre>`); continue;
}
const h = t.match(/^(#{1,6})\s+(.*)$/);
if (h) { const n = Math.min(h[1].length + 1, 4); out.push(`<h${n}>${inl(h[2])}</h${n}>`); i++; continue; }
if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) { out.push("<hr>"); 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(`<div class="tw"><table><thead><tr>${head.map((c) => `<th>${inl(c)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inl(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
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(`<div class="call ${k}"><div class="ct">${CALL[k]}</div>${pageMd(q.join("\n"), id, rev)}</div>`);
} else out.push(`<blockquote>${pageMd(q.join("\n"), id, rev)}</blockquote>`);
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 ? `<li class="task">${task[1] === " " ? "☐" : "☑"} ${inl(task[2])}</li>` : `<li>${inl(it)}</li>`);
}
out.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`); 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(`<p>${para.map(inl).join("<br>")}</p>`);
}
return out.join("");
}
function renderAsk() {
const a = S.approvals.find((x) => x.bot === S.open);
if (!a) { $("ask").innerHTML = ""; return; }

View File

@ -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<u32>)>,
/// 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::<Vec<_>>().join("\n"));
}
// 頁面: what this bot already keeps, so a routine or a follow-up updates the same page.
let mine: Vec<String> = 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<String> = 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));

View File

@ -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);

View File

@ -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<Block>)> {
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,6 +577,12 @@ 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();
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();
@ -556,6 +590,113 @@ impl App {
}
}
}
}
/// ```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;
@ -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>(), _ => 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"));
}
}

232
src/app/pages_ui.rs Normal file
View File

@ -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<Option<u32>> = 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<crate::pages::Page> = 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);
}
}

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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<Value> {
"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::<String>()),
"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<u64>,
}
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() {

View File

@ -13,6 +13,7 @@ mod desktop;
mod diagram;
mod platform;
mod mic;
mod pages;
#[cfg(target_os = "linux")]
mod x11;
#[cfg(target_os = "linux")]

View File

@ -323,6 +323,11 @@ pub struct State {
/// 技能 every bot can load.
#[serde(default)]
pub skills: Vec<crate::memory::Skill>,
/// 頁面 the bots made and keep up to date.
#[serde(default)]
pub pages: Vec<crate::pages::Page>,
#[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![],

419
src/pages.rs Normal file
View File

@ -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<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);
}
}

View File

@ -131,6 +131,8 @@ pub struct View {
pub bots: Vec<Value>,
pub approvals: Vec<Value>,
pub messages: HashMap<u64, Vec<Value>>,
/// 頁面, for the phone's page view.
pub pages: Vec<crate::pages::Page>,
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<u64, String>) -> Option<Value> {
pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pages: &[crate::pages::Page]) -> Option<Value> {
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<u64, String>) ->
ToolStatus::Denied => "denied",
ToolStatus::Failed => "failed",
};
let page = (c.name == "write_page").then(|| serde_json::from_str::<Value>(&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::<Value>(&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,
}
}