@@ -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 `${p.revision > 1 && p.note ? `
最近更新:${esc(p.note)}
` : ""}
`;
+}
+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) => `| ${inl(c)} | `).join("")}
${rows.map((r) => `${r.map((c) => `| ${inl(c)} | `).join("")}
`).join("")}
`);
+ 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