From 6d6461e4f3a838c461f58a6bdaa898d8546e4ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 01:26:33 +0800 Subject: [PATCH 01/11] Server setup: Tailscale address instead of a domain, and a no-questions mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.sh offers the computer's Tailscale address (HTTPS from tailscale serve, Caddy not started, nothing open to the internet) — docker-compose.tailscale.yml - ANYBOT_SETUP_USER / ANYBOT_SETUP_PASSWORD [/ ANYBOT_SETUP_DOMAIN] answer the questions, for scripts and deployment tools; install.sh server passes them through Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- deploy/server/docker-compose.tailscale.yml | 9 ++++ deploy/server/setup.sh | 49 +++++++++++++++++++--- install.sh | 6 ++- 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 deploy/server/docker-compose.tailscale.yml diff --git a/deploy/server/docker-compose.tailscale.yml b/deploy/server/docker-compose.tailscale.yml new file mode 100644 index 0000000..a6dee3e --- /dev/null +++ b/deploy/server/docker-compose.tailscale.yml @@ -0,0 +1,9 @@ +# Tailscale instead of a public domain: only devices in your tailnet reach this server, and +# Tailscale gives it HTTPS (`tailscale serve`), so Caddy isn't started and ports 80/443 stay closed. +# setup.sh turns this on (COMPOSE_FILE in .env) when you pick 「用 Tailscale」. +services: + caddy: + profiles: ["caddy"] + relay: + ports: + - "127.0.0.1:8780:8080" diff --git a/deploy/server/setup.sh b/deploy/server/setup.sh index 3f065ca..b243efc 100755 --- a/deploy/server/setup.sh +++ b/deploy/server/setup.sh @@ -5,10 +5,17 @@ # ./setup.sh --dry-run ask and write the files, but don't start anything # # Run it again any time: it offers to keep the current settings (to update and restart). +# +# Without questions (scripts, Ansible…): give the answers as environment variables. +# ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… ANYBOT_SETUP_DOMAIN=anybot.example.com ./setup.sh +# leave out ANYBOT_SETUP_DOMAIN on a computer with Tailscale to use its Tailscale address. +# An existing setup is kept (updated and restarted). set -u cd "$(dirname "$0")" DRY=0 [ "${1:-}" = "--dry-run" ] && DRY=1 +AUTO=0 +[ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ] && AUTO=1 # ---------- looks ---------- if [ -t 1 ]; then @@ -64,6 +71,8 @@ ask() { # choose VAR "問題" "選項|說明" ... (↑↓ and Enter) choose() { + # Without questions: the first choice (keep the setup, use Tailscale). + if [ "$AUTO" = 1 ]; then printf -v "$1" '%s' 0; return; fi local __v=$1 title=$2; shift 2 local opts=("$@") sel=0 n=$# key rest i while :; do @@ -105,7 +114,7 @@ spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest return $rc } -pause() { printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } +pause() { [ "$AUTO" = 1 ] && return; printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } die() { card_close; printf '\n'; exit 1; } # ---------- 0. this server ---------- @@ -141,8 +150,20 @@ fi if [ "${SKIP:-0}" != 1 ]; then # ---------- 1. the address ---------- - AT=1; MSG="" - while :; do + AT=1; MSG=""; TS=0 + # On a tailnet: offer its address (HTTPS from Tailscale, no domain, nothing open to the internet). + TSNAME=$(tailscale status --json 2>/dev/null | sed -n 's/.*"DNSName": *"\([^"]*\)\.".*/\1/p' | head -1) + if [ "$AUTO" = 1 ] && [ -n "${ANYBOT_SETUP_DOMAIN:-}" ]; then DOMAIN=${ANYBOT_SETUP_DOMAIN#https://}; TSNAME=""; TS=2; fi + if [ "$AUTO" = 1 ] && [ "$TS" = 0 ] && [ -z "$TSNAME" ]; then + header; card_open "網址"; bad "這台沒有 Tailscale:請加上 ANYBOT_SETUP_DOMAIN=你的網域"; die + fi + if [ -n "$TSNAME" ]; then + choose HOW "手機和其他電腦用哪個網址連到這台?" \ + "用 Tailscale:https://$TSNAME|只有你 Tailscale 裡的手機和電腦連得到。不用網域,也不用開 80、443 埠" \ + "用自己的網域|任何地方都連得到(網域要先指到這台)" + if [ "$HOW" = 0 ]; then DOMAIN=$TSNAME; TS=1; fi + fi + while [ "$TS" = 0 ]; do header; card_open "手機和其他電腦用哪個網址連到這台?" note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" line "" @@ -164,9 +185,11 @@ if [ "${SKIP:-0}" != 1 ]; then break done + [ "$TS" = 2 ] && TS=0 # ---------- 2. the account ---------- AT=2; MSG="" - while :; do + if [ "$AUTO" = 1 ]; then USERNAME=$ANYBOT_SETUP_USER; PW1=$ANYBOT_SETUP_PASSWORD; BRAIN="雲端大腦"; fi + while [ "$AUTO" = 0 ]; do header; card_open "你的 AnyBot 帳號" note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。" line "" @@ -199,6 +222,10 @@ if [ "${SKIP:-0}" != 1 ]; then q ANTHROPIC_API_KEY "$ANT" q OPENAI_API_KEY "$OAI" q GEMINI_API_KEY "$GEM" + if [ "$TS" = 1 ]; then + q ANYBOT_TAILSCALE 1 + q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml" + fi } >.env chmod 600 .env umask 022 @@ -223,6 +250,16 @@ if [ -z "${ANYBOT_BUILD_JOBS:-}" ] && [ -r /proc/meminfo ]; then [ "$mem" -lt 3000 ] && warn "記憶體只有 ${mem} MB:編譯會比較久。被中斷的話先加 2 GB swap(見 docs/install.md)" fi spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die +[ "${ANYBOT_TAILSCALE:-}" = 1 ] && TS=1 +if [ "${TS:-0}" = 1 ]; then + # Tailscale gives the address HTTPS and passes it to the account server (remembered across reboots). + if tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1 \ + || sudo tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1; then + ok "Tailscale:https://$DOMAIN → 帳號伺服器" + else + bad "沒辦法設定 tailscale serve。請執行:sudo tailscale serve --bg --https=443 http://127.0.0.1:8780" + fi +fi # The brain joins the account (and makes it, the first time). f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined="" @@ -246,7 +283,9 @@ for _ in $(seq 1 120); do printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1 done printf '\r\e[K' -if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"; else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi +if [ -n "$https" ]; then ok "https://$DOMAIN 可以連" +elif [ "${TS:-0}" = 1 ]; then warn "https://$DOMAIN 還連不到。Tailscale 的 HTTPS 要在管理頁打開(DNS › HTTPS Certificates),看設定:tailscale serve status" +else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi card_close printf '\n' diff --git a/install.sh b/install.sh index 98826f6..4d2b42b 100755 --- a/install.sh +++ b/install.sh @@ -263,9 +263,11 @@ case $MODE in find_source step "設定和啟動" info "接下來會問網域、帳號、密碼。" - [ -n "$TTY" ] || die "需要一個終端機回答問題。" "在伺服器上直接執行:cd $SRC && ./install.sh server" cd "$SRC/deploy/server" - if [ "${DOCKER_SUDO:-0}" = 1 ]; then exec $SUDO -E ./setup.sh "$@" <"$TTY"; else exec ./setup.sh "$@" <"$TTY"; fi + IN=$TTY + if [ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ]; then IN=/dev/null # 免問模式 + else [ -n "$TTY" ] || die "需要一個終端機回答問題。" "在伺服器上直接執行:cd $SRC && ./install.sh server(或用 ANYBOT_SETUP_USER、ANYBOT_SETUP_PASSWORD 免問)"; fi + if [ "${DOCKER_SUDO:-0}" = 1 ]; then exec $SUDO -E ./setup.sh "$@" <"$IN"; else exec ./setup.sh "$@" <"$IN"; fi ;; app|hands) From 2bc573f095c761d61b1b6815fec4b8dc76a5fb46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 01:41:41 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E9=A0=81=E9=9D=A2:=20pages=20a=20bot=20m?= =?UTF-8?q?akes=20and=20keeps=20up=20to=20date=20(like=20the=20original's?= =?UTF-8?q?=20tracking=20pages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/[/chart/]); 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 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- mobile/index.html | 189 ++++++++++++++++++- src/app.rs | 42 +++++ src/app/chat_ui.rs | 7 +- src/app/markdown_ui.rs | 160 +++++++++++++++- src/app/pages_ui.rs | 232 +++++++++++++++++++++++ src/app/panel_ui.rs | 11 +- src/app/remote_ui.rs | 6 +- src/appctl.rs | 30 ++- src/main.rs | 1 + src/model.rs | 7 + src/pages.rs | 419 +++++++++++++++++++++++++++++++++++++++++ src/remote.rs | 35 +++- 12 files changed, 1121 insertions(+), 18 deletions(-) create mode 100644 src/app/pages_ui.rs create mode 100644 src/pages.rs 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(`
      ${items.join("")}
    `); 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("")}
    ` : `
      ${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, } } From c71e8357b83b2cc462a04d4bd4562985c4eccc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 07:56:19 +0800 Subject: [PATCH 03/11] Hands: screen tools for the account's other computers; one pop-up per step; clearer TLS errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The brain's bots get computer_use_* for 手腳 (devices), as the prompt already said; before, they only had shell there and fell back to scrot, which can't see a Wayland desktop - After saying yes in the 「使用電腦工具」 pop-up (it shows the step), that same step isn't asked again (sending messages still asks every time) - A certificate failure says so instead of 「網路連線出了問題」 - install.sh restarts the background AnyBot after an update Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- install.sh | 4 ++++ src/agent.rs | 19 +++++++++++++++---- src/app.rs | 2 ++ src/remote.rs | 5 ++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 4d2b42b..4d112b0 100755 --- a/install.sh +++ b/install.sh @@ -292,6 +292,10 @@ case $MODE in sh "$SRC/deploy/linux/install.sh" >/dev/null || die "安裝失敗。" "執行 sh $SRC/deploy/linux/install.sh 看錯誤。" ok "程式放在 ~/.local/bin/anybot" [ "$MODE" = app ] && ok "應用程式選單裡有 AnyBot" + # 更新: the one running in the background (anybot setup's service) switches to the new one. + if systemctl --user is-active --quiet anybot 2>/dev/null; then + systemctl --user restart anybot && ok "重新啟動背景執行的 AnyBot(換成新版)" + fi fi fi PATH="$HOME/.local/bin:$PATH" diff --git a/src/agent.rs b/src/agent.rs index 59231fe..7b799dc 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -630,6 +630,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // Permission to use computers at all, and this Mac in particular, is asked for // when it's needed instead of the step failing quietly. let mut gate: Result<(), String> = Ok(()); + // The person just said yes to this very step in a switch's pop-up (it shows the + // step): no second pop-up for it. + let mut said_yes = false; // 直接操作: like Grok Bot after its one-time setup ("讓 Grok Bot 在你的電腦上開啟檔案和 // 執行任務。自動審核仍會先檢查所有操作", ENHi8c), nothing is asked except what the // safety checks and the person's "一律詢問" rules stop. @@ -642,8 +645,11 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if !tools_on { let reason = format!("讓 {} 使用電腦工具:{}\n(在 {} 上)", job.via_name, what, t.name); match ask_person(ApprovalKind::EnableTools, reason, "tools:on".into()) { - ApprovalAnswer::Once => {} - ApprovalAnswer::Always(_) => tools_on = true, + ApprovalAnswer::Once => said_yes = true, + ApprovalAnswer::Always(_) => { + tools_on = true; + said_yes = true; + } ApprovalAnswer::Reject => gate = Err("使用者沒有允許使用電腦工具。請改用文字回答,或問使用者想怎麼做。".into()), } } @@ -654,8 +660,11 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on { let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name); match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) { - ApprovalAnswer::Once => {} - ApprovalAnswer::Always(_) => local_on = true, + ApprovalAnswer::Once => said_yes = true, + ApprovalAnswer::Always(_) => { + local_on = true; + said_yes = true; + } ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)), } } @@ -764,6 +773,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let approved = match decision { _ if gate.is_err() => Err((gate.clone().unwrap_err(), ToolStatus::Denied)), Decision::Allow => Ok(()), + // Sending (Messages, mail…) still asks every time, even right after a switch. + Decision::Ask { .. } if said_yes && !(is_mac && crate::macapps::is_send(&call.name)) => Ok(()), Decision::Deny(r) => Err((format!("被安全規則擋下:{r}"), ToolStatus::Denied)), Decision::Ask { reason, grant } => { let machine = target.as_ref().map(|t| t.name.clone()).unwrap_or_default(); diff --git a/src/app.rs b/src/app.rs index 0770bfb..4a6172e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -698,6 +698,8 @@ impl App { if crate::computer::screen_supported() { screens.push(crate::model::THIS_COMPUTER_NAME.to_string()); } + // …and the account's other computers (手腳): their own AnyBot looks and clicks there. + screens.extend(devices.iter().cloned()); if cfg!(target_os = "macos") { tool_specs.extend(crate::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE)); } diff --git a/src/remote.rs b/src/remote.rs index f032fd3..6c5517e 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -720,6 +720,10 @@ pub fn why_unreachable(e: &ureq::Error) -> String { use std::io::ErrorKind as K; use ureq::Error as E; let text = e.to_string().to_lowercase(); + // A TLS failure often comes wrapped as an I/O error: look at the words first. + if text.contains("certificate") || text.contains("tls") || text.contains("handshake") { + return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string(); + } let w = match e { E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了", @@ -731,7 +735,6 @@ pub fn why_unreachable(e: &ureq::Error) -> String { K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路", _ => "網路連線出了問題", }, - _ if text.contains("tls") || text.contains("certificate") => "和伺服器的安全連線失敗(伺服器的憑證有問題)", _ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", _ => "網路連線出了問題", }; From 39bd19e9dfb9510f47bebd03176af35f8dc357e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:02:57 +0800 Subject: [PATCH 04/11] install.sh: an update needs no sudo when the system packages are already there Fresh systems still fetch the package lists first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- install.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 4d112b0..16e8923 100755 --- a/install.sh +++ b/install.sh @@ -95,7 +95,6 @@ linux_packages() { # $1: build | app | hands | server case $PM in apt-get) - $SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看" case $1 in server) set -- curl ca-certificates git ;; build) set -- build-essential pkg-config git curl ca-certificates libasound2-dev ;; @@ -106,8 +105,19 @@ linux_packages() { libxkbcommon-x11-0 "libgl1|libgl1-mesa-glx" xdg-utils libnotify-bin libsecret-tools espeak-ng zenity \ xclip wl-clipboard ffmpeg fonts-noto-cjk ;; esac + # A fresh system has no package lists yet: fetch them first, or nothing can be picked. + if ! ls /var/lib/apt/lists/*_Packages >/dev/null 2>&1; then + $SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看" + UPDATED=1 + fi pkgs=$(apt_pick "$@" | tr '\n' ' ') - info "apt-get install $pkgs" + # Already there (an update): nothing to install, no sudo password needed. + missing="" + for p in $pkgs; do dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"; done + if [ -z "$missing" ]; then info "都已經裝好了"; return 0; fi + pkgs=$missing + [ -n "${UPDATED:-}" ] || $SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看" + info "apt-get install$pkgs" $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $pkgs >/dev/null || die "安裝系統套件失敗。" "請看上面的錯誤,或手動執行:sudo apt-get install $pkgs" ;; dnf) From 86158816349a14a4b916341b829a1016b6deb9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:05:29 +0800 Subject: [PATCH 05/11] Screens: a brain without a screen of its own uses its hands' screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computer_use_* name the computer whenever the screen is another computer's, and with no computer named the step goes to a screen that is really there (the bot's desktop, this computer's, then a 手腳 computer) instead of a brain's missing one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- src/agent.rs | 12 +++++++++++- src/computer.rs | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 7b799dc..8db8928 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -145,9 +145,19 @@ fn screen_target(job: &Job, args: &str) -> Result { if let Some(t) = job.computers.iter().find(|t| t.name == job.default_computer && has_screen(t)) { return Ok(t.clone()); } + // Not named: a screen that is really there — the bot's desktop, this computer's, then one of + // the account's other computers (a brain on a server has no screen of its own). + let real = |t: &&tools::Target| match t.place { + tools::Place::Box { .. } => crate::cloudbox::available(), + tools::Place::Local { .. } => computer::screen_supported(), + _ => false, + }; job.computers .iter() - .find(|t| matches!(t.place, tools::Place::Box { .. })) + .find(|t| matches!(t.place, tools::Place::Box { .. }) && real(t)) + .or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. }) && real(t))) + .or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. }))) + .or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Box { .. }))) .or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. }))) .cloned() .ok_or_else(|| "找不到可以操作螢幕的電腦".to_string()) diff --git a/src/computer.rs b/src/computer.rs index 44a1ed6..9fce0fc 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -210,7 +210,10 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec { json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.", "parameters": {"type": "object", "properties": {}}}), ]; - if computers.len() > 1 { + // Named whenever there is a choice, or the one screen isn't this computer or the bot's own + // (a 手腳 computer): the bot should say whose screen it means. + let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME]; + if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) { for t in v.iter_mut() { t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": format!("Whose screen. Default: {default}. {} is the bot's own Linux desktop; {} is the person's Mac (use it for the person's own apps).", crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME)}); From 370dbd91f7605d55f3cad8eb9e8d8d7794543953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:10:13 +0800 Subject: [PATCH 06/11] Hands: their own virtual screen, even next to a logged-in desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A service set up without a screen (over SSH, or on Wayland) used to pick up the desktop session's DISPLAY/WAYLAND_DISPLAY from systemd, so screenshots waited for a 「分享螢幕」 answer nobody could give. Now it starts its own virtual screen; `anybot setup --real-screen` keeps the desktop. `anybot setup --refresh-service` rewrites the start script after an update (install.sh runs it), so joined computers pick up such changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- install.sh | 4 ++-- src/setup.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 16e8923..cff4f5c 100755 --- a/install.sh +++ b/install.sh @@ -303,8 +303,8 @@ case $MODE in ok "程式放在 ~/.local/bin/anybot" [ "$MODE" = app ] && ok "應用程式選單裡有 AnyBot" # 更新: the one running in the background (anybot setup's service) switches to the new one. - if systemctl --user is-active --quiet anybot 2>/dev/null; then - systemctl --user restart anybot && ok "重新啟動背景執行的 AnyBot(換成新版)" + if [ -f "$HOME/.local/bin/anybot-run" ] && systemctl --user is-enabled --quiet anybot 2>/dev/null; then + "$HOME/.local/bin/anybot" setup --refresh-service >/dev/null 2>&1 && ok "重新啟動背景執行的 AnyBot(換成新版)" fi fi fi diff --git a/src/setup.rs b/src/setup.rs index 17f96a2..a4cd7ab 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -795,6 +795,42 @@ impl Plan { } } +/// `anybot setup --refresh-service` (install.sh runs it after an update): write the start script +/// again — a newer AnyBot may start differently — and restart. The computer stays joined as it was, +/// and a screen remembered at setup stays. +fn refresh_service() -> i32 { + let Some(home) = dirs::home_dir() else { return 1 }; + let launcher = home.join(".local/bin/anybot-run"); + let Ok(old) = std::fs::read_to_string(&launcher) else { + println!("這台沒有設成開機自動執行,不用更新服務。"); + return 0; + }; + let role = if old.contains("accessibility bus") { Role::Hands } else { Role::Brain }; + let display = old.lines().find_map(|l| l.strip_prefix("export DISPLAY=\"${DISPLAY:-").and_then(|r| r.strip_suffix("}\""))).unwrap_or("").to_string(); + let env = Env { linux: cfg!(target_os = "linux"), display, systemd: true }; + let exe = match std::env::current_exe().and_then(|p| p.canonicalize()) { + Ok(e) => e, + Err(e) => { + eprintln!("✗ 找不到 AnyBot 程式:{e}"); + return 1; + } + }; + if let Err(e) = std::fs::write(&launcher, launcher_script(role, &exe, &env)) { + eprintln!("✗ 寫入 {} 失敗:{e}", launcher.display()); + return 1; + } + match systemctl(&["restart", UNIT]) { + Ok(_) => { + println!("已更新並重新啟動 AnyBot 服務。"); + 0 + } + Err(e) => { + eprintln!("✗ 重新啟動失敗:{e}"); + 1 + } + } +} + fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String { let q = |s: &str| format!("'{}'", s.replace('\'', "'\\''")); let mut s = String::from("#!/bin/sh\n# Started by systemd (anybot.service); written by `anybot setup`.\n"); @@ -806,11 +842,17 @@ fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String { if role == Role::Hands { s += "# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels.\n"; s += "export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\n"; + let real_screen = std::env::args().any(|a| a == "--real-screen"); if env.has_display() && env.display != "wayland" && env.display != "desktop" { s += &format!("# The screen this computer had when it was set up.\nexport DISPLAY=\"${{DISPLAY:-{}}}\"\n", env.display); if let Ok(x) = std::env::var("XAUTHORITY") { s += &format!("export XAUTHORITY=\"${{XAUTHORITY:-{x}}}\"\n"); } + } else if !real_screen { + // Set up without a screen (over SSH, or on Wayland): the bots get a virtual screen of + // their own, even when a desktop session is logged in here — nobody would be there to + // answer its 「允許分享螢幕」 dialog. `anybot setup --real-screen` uses the desktop. + s += "unset DISPLAY WAYLAND_DISPLAY XDG_SESSION_TYPE\n"; } s += "if [ -z \"$DISPLAY\" ] && [ -z \"$WAYLAND_DISPLAY\" ] && command -v xvfb-run >/dev/null; then\n"; s += " # No screen: a virtual one, a window manager, and a session bus for the accessibility bus.\n"; @@ -843,9 +885,12 @@ const VALUED: [&str; 4] = ["--server", "--user", "--name", "--brain-name"]; pub fn run(args: &[String]) -> i32 { let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); if args.iter().any(|a| a == "-h" || a == "--help") { - println!("anybot setup:把這台電腦接到你的 AnyBot。\n\n anybot setup 網址 配對碼 (手機或電腦的 AnyBot 按「加一台電腦」會給你這一行)\n anybot setup 一個畫面:填網址和配對碼(或帳號密碼)\n 加 --brain 這台當大腦,不當手腳\n 加 --no-service 不設開機自動執行\n\n給程式用:anybot setup --server 網址 --user 帳號 [--brain-name 大腦名字],密碼放在 ANYBOT_ACCOUNT_PASSWORD。"); + println!("anybot setup:把這台電腦接到你的 AnyBot。\n\n anybot setup 網址 配對碼 (手機或電腦的 AnyBot 按「加一台電腦」會給你這一行)\n anybot setup 一個畫面:填網址和配對碼(或帳號密碼)\n 加 --brain 這台當大腦,不當手腳\n 加 --no-service 不設開機自動執行\n 加 --real-screen 讓 Bot 用這台登入中的桌面(預設用自己的虛擬螢幕)\n\n給程式用:anybot setup --server 網址 --user 帳號 [--brain-name 大腦名字],密碼放在 ANYBOT_ACCOUNT_PASSWORD。"); return 0; } + if args.iter().any(|a| a == "--refresh-service") { + return refresh_service(); + } let role = if args.iter().any(|a| a == "--brain") { Role::Brain } else { Role::Hands }; let mut pos = vec![]; let mut skip = false; From cd7a4207cc28b874765b95898903f780adca8ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:17:07 +0800 Subject: [PATCH 07/11] Browser tools on a brain without a browser go to its hands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brain on a server has no AnyBot browser, and browser_open/read were sent to it even when the bot named a 手腳 computer. Now a named computer wins, and with none named the hands' browser is used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- src/agent.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index 8db8928..876cf40 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -627,6 +627,12 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // …and so does screen control: it's the person's own screen. let target = if is_screen { screen_target(&job, &call.args) + } else if is_web && device_named(&job, &call.args).is_some() { + // The browser on another computer of the account, as named. + target_for(&job, &call.args) + } else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) { + // No browser here (a brain on a server): the 手腳 computer's. + job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string()) } else if is_mac || web::uses_this_computer(&call.name, &call.args) { job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string()) } else { From f2d89ca358fe729e57344e600aeb2da2aa63b4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:21:34 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E7=9C=8B=E5=9C=96=E5=B0=8F=E5=B9=AB?= =?UTF-8?q?=E6=89=8B=20also=20looks=20at=20the=20newest=20screenshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model that can't see pictures got only 「截圖已拍好,但看不到」 for computer_use_screenshot. Now the newest screenshot is described by a model of the person's that can (windows, text, buttons and roughly where they are); older screenshots stay a short note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- src/providers.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/providers.rs b/src/providers.rs index c195298..f169b95 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -372,7 +372,7 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven let mut blind_copy: Option = None; loop { // A model that can't see pictures gets the request without them: the person's pictures - // described by a model that can (看圖小幫手), screenshots as a short note. + // and the newest screenshot described by a model that can (看圖小幫手). if !sees_images(req) && blind_copy.is_none() { blind_copy = Some(if DESCRIBING.with(|d| d.get()) { without_images(req) } else { with_descriptions(req, cancel, send) }); } @@ -458,6 +458,8 @@ fn kept_images(history: &[Message]) -> Vec { const OLD_SCREENSHOT_NOTE: &str = "(較早的截圖已省略)"; /// Said in place of a screenshot when the model can't take pictures. const BLIND_SCREENSHOT_NOTE: &str = "(截圖已拍好,但這個模型看不到圖片,所以沒有附上。請改用文字方式確認畫面,例如讀取視窗或網頁的文字。)"; +/// What a helper is asked about a screenshot. +const SCREEN_ASK: &str = "這是一台電腦螢幕的截圖。請描述:開著哪些視窗或 App、畫面上的文字(照抄重要的字)、按鈕和欄位,以及它們大約的位置(以這張圖的像素座標,左上角是 0,0)。不要猜看不清楚的字。"; const BLIND_ATTACHMENT_NOTE: &str = "(使用者附了圖片,但這個模型看不到圖片。)"; /// Models (base url + name) that refused a picture: later requests leave pictures out. @@ -631,10 +633,25 @@ fn describe(picture: &str, ask: &str, not: &ChatRequest, cancel: &AtomicBool) -> fn with_descriptions(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> ChatRequest { let mut r = req.clone(); let mut told = false; - for m in r.history.iter_mut().filter(|m| !m.images.is_empty()) { + // The newest screenshot is described too (what the bot needs to act next); older ones stay a note. + let newest_shot = r.history.iter().rposition(|m| m.role == Role::Tool && !m.images.is_empty()); + for (i, m) in r.history.iter_mut().enumerate().filter(|(_, m)| !m.images.is_empty()) { if m.role == Role::Tool { - m.images.clear(); - m.text = if m.text.is_empty() { BLIND_SCREENSHOT_NOTE.to_string() } else { format!("{}\n{BLIND_SCREENSHOT_NOTE}", m.text) }; + let pictures = std::mem::take(&mut m.images); + let seen = if Some(i) == newest_shot { + if !told && !notes().lock().unwrap().contains_key(&picture_key(&pictures[0])) { + send(StreamEvent::Retrying("這個模型看不到圖片,正在請另一個看得懂圖的模型看這張截圖…".into())); + told = true; + } + describe(&pictures[0], SCREEN_ASK, req, cancel) + } else { + None + }; + let add = match seen { + Some((d, model)) => format!("(截圖已拍好;你看不到圖片,以下是「{model}」看過這張截圖後的描述)\n{d}"), + None => BLIND_SCREENSHOT_NOTE.to_string(), + }; + m.text = if m.text.is_empty() { add } else { format!("{}\n{add}", m.text) }; continue; } let pictures = std::mem::take(&mut m.images); From d2f1c76d01d4a05e828466bfd21a8f029a9fe7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:24:36 +0800 Subject: [PATCH 09/11] Browser steps that needed a yes ran on the brain, not on the hands named Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- src/agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent.rs b/src/agent.rs index 876cf40..6409e30 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -817,7 +817,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() }; let (out, ok) = if is_mac { crate::macapps::run(&call.name, &call.args) - } else if let (true, Some(host)) = (is_screen, on_device.as_ref()) { + } else if let (true, Some(host)) = (is_screen || is_web, on_device.as_ref()) { match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) { Ok(v) => { image = v["image"].as_str().map(String::from); From b05bec66f456ede12a571f5a028ccfa36d50998c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:33:23 +0800 Subject: [PATCH 10/11] Pages: phone page fits narrow screens; long stat values a size smaller; ANYBOT_SHOT_PAGE Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- mobile/index.html | 13 ++++++++----- src/app.rs | 6 ++++++ src/app/markdown_ui.rs | 4 +++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/mobile/index.html b/mobile/index.html index a9da53c..bccc044 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -101,7 +101,9 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400} .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{padding:18px 18px calc(env(safe-area-inset-bottom) + 40px);max-width:760px;margin:0 auto;line-height:1.65;font-size:15.5px;box-sizing:border-box;width:100%;min-width:0;overflow-wrap:anywhere} +#pscroll{overflow-x:hidden} +#v-page header{min-width:0} .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} @@ -117,14 +119,15 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px; .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} +.tw{overflow-x:auto;margin:0 0 1em;border:1px solid var(--line);border-radius:10px;max-width:100%} +.page td{min-width:5em;overflow-wrap:normal;word-break:normal} .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)} +.stat .k{font-size:12px;color:var(--text2)}.stat .v{font-size:21px;font-weight:700;line-height:1.3}.stat .v.long{font-size:15.5px}.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)} @@ -144,7 +147,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px; .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%} +#pver{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:4px 6px;font-size:13px;color:var(--text);max-width:42%;flex:none} .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} @@ -586,7 +589,7 @@ function pageMd(text, id, rev) { 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])}
    ` : ""}
    `); + 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") { diff --git a/src/app.rs b/src/app.rs index 4a6172e..ef18197 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3054,6 +3054,12 @@ impl App { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); } } + // ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first). + if self.frames == need * 3 / 4 { + if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) { + self.page_view = Some((id, None)); + } + } if self.frames >= need && !self.shot_requested { self.shot_requested = true; let slot = self.shot.clone(); diff --git a/src/app/markdown_ui.rs b/src/app/markdown_ui.rs index 64bb5e3..b64b322 100644 --- a/src/app/markdown_ui.rs +++ b/src/app/markdown_ui.rs @@ -609,7 +609,9 @@ impl App { 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()); + // A long value (a phrase, not a number) a size smaller, so it doesn't fill the tile. + let size = if value.chars().count() > 10 { 15.0 } else { 20.0 }; + ui.add(egui::Label::new(RichText::new(value).size(size).strong().color(p.text)).wrap()); if !note.is_empty() { ui.add(egui::Label::new(RichText::new(note).size(11.5).color(p.text3)).wrap()); } From c0dfef0995b14b7b378bdf7250a4d41c472ccf74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 08:35:47 +0800 Subject: [PATCH 11/11] =?UTF-8?q?Docs:=20=E9=A0=81=E9=9D=A2,=20Tailscale?= =?UTF-8?q?=20servers,=20no-questions=20install,=20hands'=20virtual=20scre?= =?UTF-8?q?en=20and=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- FEATURES.md | 9 +++++++++ README.md | 7 ++++--- docs/multi-device.md | 9 ++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index d7c7e2f..c358aea 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1259,6 +1259,15 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開) - Bot 用 `open_for_person` 把它做好的網頁、圖表、報告、圖片、文件在你的螢幕上打開(網址也可以)。在 Bot 的電腦上做的檔案會先複製到這台 Mac。 +### 頁面(Bot 做、Bot 持續更新的報告) + +- 你請 Bot 追蹤、整理、比較一件之後還會回來看的事(例如「幫我追蹤一下台灣 FSD 狀況」),它用 `write_page` 做成一頁:先寫現況,再寫細節和來源(附日期)。回答裡用「[標題](page:ID)」連到它,回答下面出現頁面卡片(標題、開頭幾行、頁面圖示、「頁面 · 更新時間」、最近一次更新了什麼),點一下打開。 +- 頁面可以放表格、Mermaid 圖表(圓餅、長條、折線、時間軸…),以及頁面專用的區塊:```stats 數字卡片、```steps 進度(✓ 完成、◉ 進行中、○ 還沒到)、`> [!NOTE]`/`[!TIP]`/`[!WARNING]`/`[!IMPORTANT]` 提示框。這些區塊在聊天裡也能用。 +- 要定期追蹤時,Bot 會同時設例行任務:每次先 `read_page`、查新消息,有實質進展才更新頁面並告訴你;沒有就說「沒有新消息」,頁面不動。 +- 每次更新保留上一版(最多 10 版),打開頁面後下方「版本紀錄」可以看舊版。也可以複製成 Markdown、存成檔案(到「下載」)、刪除。 +- 右側面板「資料庫」最上面列出這個 Bot 的頁面。手機也能看:聊天裡的卡片、頁面連結、完整頁面(圖表由 AnyBot 畫好傳過去)。 +- 頁面存在大腦上,螢幕電腦和手機看到的是同一份。 + ### 生活卡片 - Bot 用 `show_card` 把機票、餐廳或地點、天氣、商品、活動整理成卡片顯示在聊天裡:圖示、標題、副標、最多 12 列「項目/內容」,以及「查看來源 ↗」。 diff --git a/README.md b/README.md index e309902..815ffc7 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,13 @@ **步驟 1:架伺服器**(只做一次) -需要:一台 Linux 主機(2 GB 以上記憶體)、一個網域,網域的 A 紀錄要指到這台主機,80 和 443 埠要打開。 +需要:一台 Linux 主機(2 GB 以上記憶體),加上一個網域(A 紀錄指到這台、80 和 443 埠打開)或 Tailscale(不用網域,只有你的裝置連得到)。 ```sh curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server ``` -只會問三件事:**網域、帳號、密碼**。它會自動裝 Docker、申請 HTTPS 憑證、建立帳號、啟動大腦。完成畫面會告訴你網址。 +只會問三件事:**網址(網域或 Tailscale)、帳號、密碼**。它會自動裝 Docker、申請 HTTPS 憑證、建立帳號、啟動大腦。完成畫面會告訴你網址。 > 沒有伺服器?也可以先拿一台 Mac 當大腦,見 [docs/multi-device.md](docs/multi-device.md)(做法 B)。 @@ -133,7 +133,8 @@ make install # 安裝:Mac → ~/Applications/AnyBot.app;Linux → ~/.loca | 方式 | 用在 | 說明 | |---|---|---| -| **Docker Compose**(預設) | 伺服器 | `install.sh server` 用的就是它:帳號伺服器、大腦、Caddy(自動 HTTPS)三個容器,資料放在 Docker 磁碟區,重開機自動啟動。設定檔:[deploy/server/docker-compose.yml](deploy/server/docker-compose.yml) | +| **Docker Compose**(預設) | 伺服器 | `install.sh server` 用的就是它:帳號伺服器、大腦、Caddy(自動 HTTPS)三個容器,資料放在 Docker 磁碟區,重開機自動啟動。用 Tailscale 網址時不開 Caddy,改由 `tailscale serve` 提供 HTTPS。設定檔:[deploy/server/docker-compose.yml](deploy/server/docker-compose.yml) | +| 免問安裝(腳本、Ansible…) | 伺服器 | 答案用環境變數給:`ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… [ANYBOT_SETUP_DOMAIN=網域] sh install.sh server`;不給網域時用這台的 Tailscale 網址 | | Portainer、Coolify、Dokploy… | 已經在用這些管理工具的伺服器 | 它們都吃標準的 `docker-compose.yml`:把 repo 加進去、指定 `deploy/server/docker-compose.yml`、在環境變數填 `ANYBOT_USER`、`ANYBOT_PASSWORD`、`ANYBOT_RELAY_PUBLIC_URL`。若工具自己處理 HTTPS,就拿掉 `caddy` 那一段,把網域導到 `relay` 的 8080 埠 | | **systemd**(使用者服務) | 手腳、沒螢幕的 Linux 大腦 | `anybot setup` 會自動建立 `anybot.service`;登出後也要繼續跑:`sudo loginctl enable-linger $USER` | | **launchd** | Mac 當大腦或帳號伺服器 | 見 [docs/multi-device.md](docs/multi-device.md) | diff --git a/docs/multi-device.md b/docs/multi-device.md index c6e126b..abb72a9 100644 --- a/docs/multi-device.md +++ b/docs/multi-device.md @@ -15,7 +15,10 @@ ### 做法 A:放在伺服器上(推薦,Mac 關機時大腦也還在) -需要:一台 Linux 主機(2 GB 以上記憶體;更小的請先加 swap,見 [install.md](install.md) 的常見問題)、一個 A 紀錄指到它的網域、打開 80 和 443 埠。Docker 沒裝會自動裝。 +需要:一台 Linux 主機(2 GB 以上記憶體;更小的請先加 swap,見 [install.md](install.md) 的常見問題),以及下面其中一種網址。Docker 沒裝會自動裝。 + +- **Tailscale**(不用網域):那台裝好 Tailscale,並在 Tailscale 管理頁打開 HTTPS(DNS › HTTPS Certificates)。設定時會出現「用 Tailscale:https://那台.xxx.ts.net」,選它就好;只有你 Tailscale 裡的手機和電腦連得到,不用開任何埠。 +- **自己的網域**:A 紀錄指到這台,打開 80 和 443 埠,HTTPS 憑證自動申請。 ```sh curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server @@ -63,6 +66,10 @@ curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh 虛擬螢幕(Xvfb)、視窗管理員和輔助使用服務會一起裝好,Bot 看得到這台的畫面。 +這台登入了桌面(例如 GNOME)也一樣:Bot 用的是 AnyBot 自己開的虛擬螢幕,不會動到你的桌面,也不用有人在那台前面按「允許分享螢幕」。想讓 Bot 操作那台登入中的桌面,加 `--real-screen`。 + +更新:在那台再執行一次安裝的那一行(或 `cd ~/AnyBot && ./install.sh hands`),會換成新版並重新啟動,不用再配對。 + 只打 `anybot setup` 會出現一個畫面:填網址和配對碼(或改用帳號密碼)。給程式用: ```sh