Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up

The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
  then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
  routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
  (restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
  three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
  a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
  conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.

Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
王性驊 2026-11-06 20:28:08 +08:00
parent b9fc134c99
commit 39cd7e85d7
4 changed files with 366 additions and 16 deletions

View File

@ -206,6 +206,68 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
.sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px} .sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px}
.toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center} .toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center}
.toast.on{opacity:.95} .toast.on{opacity:.95}
/* 外觀: chosen by hand (the menu) over the phone's own setting */
:root[data-theme="dark"]{--bg:#121212;--side:#0e0e0e;--surface:#1f1f1f;--raise:#161616;--line:rgba(255,255,255,.08);--text:#f5f5f5;--text2:#c1c1c1;--text3:#8a8a8a;--user:rgba(255,255,255,.18)}
/* home: the main bot, the other bots, the computers, pages and routines */
.hero{display:flex;gap:14px;align-items:center;margin:14px 14px 4px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);cursor:pointer}
.hero:active{background:var(--raise)}
.hero .av{width:60px;height:60px}
.hero .mid{flex:1;min-width:0}
.hero .name{font-size:19px;font-weight:700;display:flex;gap:6px;align-items:center}
.hero .model{font-size:12px;color:var(--text3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hero .last{font-size:14px;color:var(--text2);margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.sec{font-size:13px;font-weight:600;color:var(--text3);margin:20px 18px 7px;display:flex;align-items:center;gap:6px}
.sec .n{font-weight:400}
.list{margin:0 14px;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}
.list .row{border-bottom:1px solid var(--line);padding:11px 14px;cursor:pointer}
.list .row:last-child{border-bottom:0}
.list .row.static{cursor:default}.list .row.static:active{background:none}
.row .time{font-size:12px;color:var(--text3);flex:none;align-self:flex-start;margin-top:2px}
.ico{width:36px;height:36px;border-radius:11px;background:var(--raise);display:grid;place-items:center;flex:none;color:var(--text2)}
.st-on,.st-off{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;vertical-align:1px}
.st-on{background:var(--ok)}.st-off{background:#8a8a8a}
.mini{font-size:13px;padding:6px 11px;border-radius:9px;background:var(--raise);font-weight:500;flex:none;display:inline-flex;gap:5px;align-items:center}
.homeend{height:calc(env(safe-area-inset-bottom) + 24px)}
/* chat: the bot's name opens its settings; steps fold up when a turn is done */
#v-chat{position:relative}
.hbtn{display:flex;align-items:center;gap:10px;flex:1;min-width:0;text-align:left;padding:2px 4px;border-radius:10px}
.hbtn:active{background:var(--raise)}
.hbtn .tx{min-width:0;flex:1}
.hbtn h1{font-size:17px}
.hbtn .model{font-size:11.5px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;gap:4px;align-items:center}
.fold{display:flex;gap:7px;align-items:center;font-size:13px;color:var(--text3);padding:3px 4px;cursor:pointer;user-select:none}
.fold .ic{color:var(--ok)}
.fold.run .ic{color:var(--accent)}
.acts{display:flex;gap:2px;margin:-2px 0 4px -6px}
.acts button{width:32px;height:28px;border-radius:8px;display:grid;place-items:center;color:var(--text3)}
.acts button:active{background:var(--raise);color:var(--text)}
.sugg{display:flex;flex-direction:column;gap:8px;padding:28px 16px 12px}
.sugg .av{width:64px;height:64px;margin:0 auto 6px}
.sugg .hi{font-size:21px;font-weight:700;text-align:center}
.sugg .sub{font-size:13.5px;color:var(--text3);text-align:center;margin-bottom:12px}
.sugg button{display:flex;gap:11px;align-items:center;text-align:left;padding:13px 14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);font-size:14.5px}
.sugg button:active{background:var(--raise)}
.sugg button .ic{color:var(--accent)}
.down{position:absolute;right:14px;bottom:calc(env(safe-area-inset-bottom) + 74px);width:38px;height:38px;border-radius:50%;background:var(--surface);border:1px solid var(--line);box-shadow:0 4px 16px rgba(0,0,0,.18);display:none;place-items:center;color:var(--text2);z-index:2}
.down.on{display:grid}
/* the bot's settings sheet */
.bsheet{max-height:82vh;overflow-y:auto}
.bsheet .head{display:flex;gap:12px;align-items:center;padding:10px 12px 12px}
.bsheet .head .av{width:46px;height:46px}
.bsheet .head .n{font-size:18px;font-weight:700}.bsheet .head .m{font-size:12.5px;color:var(--text3)}
.bsheet details{border-top:1px solid var(--line)}
.bsheet summary{list-style:none;display:flex;gap:10px;align-items:center;padding:13px 12px;font-size:15.5px;cursor:pointer}
.bsheet summary::-webkit-details-marker{display:none}
.bsheet summary .v{margin-left:auto;color:var(--text3);font-size:13.5px;max-width:55%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bsheet details[open] summary .chev{transform:rotate(90deg)}
.bsheet .chev{transition:transform .15s;color:var(--text3);flex:none}
.bsheet .opt{display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:10px 12px 10px 40px;font-size:14.5px;border-radius:10px}
.bsheet .opt:active{background:var(--raise)}
.bsheet .opt .ck{margin-left:auto;color:var(--accent)}
.bsheet .grp{font-size:12px;color:var(--text3);padding:8px 12px 2px 40px}
.bsheet .act{display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:13px 12px;font-size:15.5px;border-top:1px solid var(--line);border-radius:0}
.bsheet .act:active{background:var(--raise)}
.bsheet .act .ic{color:var(--text2)}
</style> </style>
</head> </head>
<body> <body>
@ -245,12 +307,12 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
<section class="view" id="v-chat"> <section class="view" id="v-chat">
<header> <header>
<button class="icon-btn" id="b-back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button> <button class="icon-btn" id="b-back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
<div class="av sm" id="chat-av"></div> <button class="hbtn" id="b-botinfo" title="這個 Bot 的設定"><div class="av sm" id="chat-av"></div><div class="tx"><h1 id="chat-name"></h1><div class="model" id="chat-model"></div></div></button>
<h1 id="chat-name"></h1>
<button class="icon-btn" id="b-new" title="開始新對話"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 5v14M5 12h14"/></svg></button> <button class="icon-btn" id="b-new" title="開始新對話"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 5v14M5 12h14"/></svg></button>
<button class="icon-btn" id="b-screen2" title="看電腦畫面"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg></button> <button class="icon-btn" id="b-screen2" title="看電腦畫面"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg></button>
</header> </header>
<div class="scroll" id="scroller"><div class="msgs" id="msgs"></div></div> <div class="scroll" id="scroller"><div class="msgs" id="msgs"></div></div>
<button class="down" id="b-down" title="到最新的訊息"></button>
<div id="ask"></div> <div id="ask"></div>
<div class="chips" id="chips"></div> <div class="chips" id="chips"></div>
<div class="composer"> <div class="composer">
@ -294,6 +356,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<div class="sheet-bg" id="sheet"><div class="sheet"> <div class="sheet-bg" id="sheet"><div class="sheet">
<button id="m-theme">外觀:跟著手機</button>
<button id="m-notify">開啟通知</button> <button id="m-notify">開啟通知</button>
<div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div> <div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>
<button id="m-mac" style="display:none">看這台 Mac 的螢幕</button> <button id="m-mac" style="display:none">看這台 Mac 的螢幕</button>
@ -303,6 +366,8 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
<button id="m-cancel">取消</button> <button id="m-cancel">取消</button>
</div></div> </div></div>
<div class="sheet-bg" id="botsheet"><div class="sheet bsheet" id="botsheet-in"></div></div>
<div class="sheet-bg" id="pair"><div class="sheet pair"> <div class="sheet-bg" id="pair"><div class="sheet pair">
<div class="pair-t">加一台電腦</div> <div class="pair-t">加一台電腦</div>
<div class="note">在那台電腦(Linux 或 Mac)的終端機貼上這一行執行:還沒裝 AnyBot 會先自動裝好,接著變成「<b id="pair-for"></b>」的手腳,開機自動執行。不用帳號密碼。</div> <div class="note">在那台電腦(Linux 或 Mac)的終端機貼上這一行執行:還沒裝 AnyBot 會先自動裝好,接著變成「<b id="pair-for"></b>」的手腳,開機自動執行。不用帳號密碼。</div>
@ -318,8 +383,45 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
// Lucide icons (ISC licence), the same set as the app. // Lucide icons (ISC licence), the same set as the app.
const LUCIDE = {"image": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" /> <circle cx=\"9\" cy=\"9\" r=\"2\" /> <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />", "file": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" />", "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />", "loader": "<path d=\"M12 2v4\" /> <path d=\"m16.2 7.8 2.9-2.9\" /> <path d=\"M18 12h4\" /> <path d=\"m16.2 16.2 2.9 2.9\" /> <path d=\"M12 18v4\" /> <path d=\"m4.9 19.1 2.9-2.9\" /> <path d=\"M2 12h4\" /> <path d=\"m4.9 4.9 2.9 2.9\" />", "circle-alert": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" /> <line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />", "minus": "<path d=\"M5 12h14\" />", "circle-x": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />", "x": "<path d=\"M18 6 6 18\" /> <path d=\"m6 6 12 12\" />", "keyboard": "<path d=\"M10 8h.01\" /> <path d=\"M12 12h.01\" /> <path d=\"M14 8h.01\" /> <path d=\"M16 12h.01\" /> <path d=\"M18 8h.01\" /> <path d=\"M6 8h.01\" /> <path d=\"M7 16h10\" /> <path d=\"M8 12h.01\" /> <rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\" />", "paperclip": "<path d=\"m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551\" />", "alarm-clock": "<circle cx=\"12\" cy=\"13\" r=\"8\" /> <path d=\"M12 9v4l2 2\" /> <path d=\"M5 3 2 6\" /> <path d=\"m22 6-3-3\" /> <path d=\"M6.38 18.7 4 21\" /> <path d=\"M17.64 18.67 20 21\" />"}; const LUCIDE = {"image": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" /> <circle cx=\"9\" cy=\"9\" r=\"2\" /> <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />", "file": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" />", "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />", "loader": "<path d=\"M12 2v4\" /> <path d=\"m16.2 7.8 2.9-2.9\" /> <path d=\"M18 12h4\" /> <path d=\"m16.2 16.2 2.9 2.9\" /> <path d=\"M12 18v4\" /> <path d=\"m4.9 19.1 2.9-2.9\" /> <path d=\"M2 12h4\" /> <path d=\"m4.9 4.9 2.9 2.9\" />", "circle-alert": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" /> <line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />", "minus": "<path d=\"M5 12h14\" />", "circle-x": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />", "x": "<path d=\"M18 6 6 18\" /> <path d=\"m6 6 12 12\" />", "keyboard": "<path d=\"M10 8h.01\" /> <path d=\"M12 12h.01\" /> <path d=\"M14 8h.01\" /> <path d=\"M16 12h.01\" /> <path d=\"M18 8h.01\" /> <path d=\"M6 8h.01\" /> <path d=\"M7 16h10\" /> <path d=\"M8 12h.01\" /> <rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\" />", "paperclip": "<path d=\"m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551\" />", "alarm-clock": "<circle cx=\"12\" cy=\"13\" r=\"8\" /> <path d=\"M12 9v4l2 2\" /> <path d=\"M5 3 2 6\" /> <path d=\"m22 6-3-3\" /> <path d=\"M6.38 18.7 4 21\" /> <path d=\"M17.64 18.67 20 21\" />"};
function ic(name, size = 16) { return `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${LUCIDE[name] || ""}</svg>`; } function ic(name, size = 16) { return `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${LUCIDE[name] || ""}</svg>`; }
// More icons (Lucide), for the home, the bot's settings and the chat.
Object.assign(LUCIDE, {
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" /> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />",
"refresh": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" /> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" /> <path d=\"M8 16H3v5\" />",
"history": "<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\" /> <path d=\"M3 3v5h5\" /> <path d=\"M12 7v5l4 2\" />",
"monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
"laptop": "<path d=\"M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z\" /> <path d=\"M20.054 15.987H3.946\" />",
"cloud": "<path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\" />",
"server": "<rect width=\"20\" height=\"8\" x=\"2\" y=\"2\" rx=\"2\" ry=\"2\" /> <rect width=\"20\" height=\"8\" x=\"2\" y=\"14\" rx=\"2\" ry=\"2\" /> <line x1=\"6\" x2=\"6.01\" y1=\"6\" y2=\"6\" /> <line x1=\"6\" x2=\"6.01\" y1=\"18\" y2=\"18\" />",
"file-text": "<path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" /> <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" /> <path d=\"M10 9H8\" /> <path d=\"M16 13H8\" /> <path d=\"M16 17H8\" />",
"chevron-right": "<path d=\"m9 18 6-6-6-6\" />",
"chevron-down": "<path d=\"m6 9 6 6 6-6\" />",
"sparkles": "<path d=\"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z\" />",
"check": "<path d=\"M20 6 9 17l-5-5\" />",
"arrow-down": "<path d=\"M12 5v14\" /> <path d=\"m19 12-7 7-7-7\" />",
"plus": "<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\" /> <path d=\"m21 21-4.3-4.3\" />",
"pen": "<path d=\"M12 20h9\" /> <path d=\"M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z\" />",
"camera": "<path d=\"M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z\" /> <circle cx=\"12\" cy=\"13\" r=\"3\" />",
"list-checks": "<path d=\"m3 17 2 2 4-4\" /> <path d=\"m3 7 2 2 4-4\" /> <path d=\"M13 6h8\" /> <path d=\"M13 12h8\" /> <path d=\"M13 18h8\" />",
});
// Times as on a phone: today 「下午 7:41」, earlier 「11/5」.
function fmtTime(ms) {
if (!ms) return "";
const d = new Date(ms), now = new Date();
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString("zh-TW", {hour: "numeric", minute: "2-digit"});
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// 外觀: follow the phone, or light or dark by hand.
const THEMES = [["", "跟著手機"], ["light", "淺色"], ["dark", "深色"]];
function applyTheme() {
let t = ""; try { t = localStorage.getItem("anybot.theme") || ""; } catch (_) {}
if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
const el = document.getElementById("m-theme");
if (el) el.textContent = "外觀:" + (THEMES.find((x) => x[0] === t) || THEMES[0])[1];
}
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const S = { const S = {
unfold: {}, pages: [], models: [],
relay: false, token: localStorage.getItem("anybot.token") || "", host: localStorage.getItem("anybot.host") || "", relay: false, token: localStorage.getItem("anybot.token") || "", host: localStorage.getItem("anybot.host") || "",
version: 0, bots: [], approvals: [], open: 0, msgs: [], screen: "box", right: false, pending: [], hostName: "", mac: false, version: 0, bots: [], approvals: [], open: 0, msgs: [], screen: "box", right: false, pending: [], hostName: "", mac: false,
polling: false, frameTimer: null, size: [0, 0], polling: false, frameTimer: null, size: [0, 0],
@ -468,6 +570,7 @@ async function start() {
} }
function apply(v) { function apply(v) {
S.bots = v.bots || []; S.approvals = v.approvals || []; S.hostName = v.host || ""; S.mac = !!v.mac; S.box = v.box; S.bots = v.bots || []; S.approvals = v.approvals || []; S.hostName = v.host || ""; S.mac = !!v.mac; S.box = v.box;
S.pages = v.pages || []; S.models = v.models || [];
// 團隊: a member only chats; the owner and admins approve and use the screens. // 團隊: a member only chats; the owner and admins approve and use the screens.
S.role = v.role || "owner"; S.role = v.role || "owner";
const member = S.role === "member"; const member = S.role === "member";
@ -476,27 +579,59 @@ function apply(v) {
renderList(); renderList();
if (S.open) { if (S.open) {
const b = S.bots.find((x) => x.id === S.open); const b = S.bots.find((x) => x.id === S.open);
if (b) { $("chat-name").textContent = b.name; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face"); } if (b) chatHead(b);
loadChat(); renderAsk(); loadChat(); renderAsk();
} }
} }
function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; } function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; }
function botRow(b, cls = "row") {
const waiting = S.approvals.some((a) => a.bot === b.id);
const state = waiting ? `<span class="wait">等你決定</span>` : b.busy ? `<span class="busy">${esc(b.status || "工作中…")}</span>` : "";
return `<div class="${cls}" data-id="${b.id}"><div class="av face">${avatarSvg(b.shape, b.color)}</div><div class="mid"><div class="name">${esc(b.name)}${b.members ? '<span class="pill">群組</span>' : ""}${b.unread ? '<span class="badge"></span>' : ""}</div>${cls === "hero" ? `<div class="model">${esc(b.via || "")}</div>` : ""}<div class="last">${state || esc(b.last || b.description || "")}</div></div>${cls === "row" && b.time ? `<div class="time">${fmtTime(b.time)}</div>` : ""}</div>`;
}
const KIND = {own: ["cloud", "Bot 自己的電腦"], this: ["monitor", "這台"], device: ["laptop", "你帳號裡的電腦"], ssh: ["server", "SSH"]};
// The home: the main bot big at the top, the other bots, then its computers, its pages and the
// routines — what the desktop shows beside the chat.
function renderList() { function renderList() {
const el = $("bots"); const el = $("bots");
if (!S.bots.length) { el.innerHTML = `<div class="empty">還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。</div>`; return; } if (!S.bots.length) { el.innerHTML = `<div class="empty">還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。</div>`; return; }
el.innerHTML = S.bots.map((b) => { const main = S.bots.find((b) => b.primary) || S.bots[0];
const waiting = S.approvals.some((a) => a.bot === b.id); const others = S.bots.filter((b) => b !== main);
const state = waiting ? `<span class="wait">等你決定</span>` : b.busy ? `<span class="busy">${esc(b.status || "工作中…")}</span>` : ""; const member = S.role === "member";
return `<div class="row" data-id="${b.id}"><div class="av face">${avatarSvg(b.shape, b.color)}</div><div class="mid"><div class="name">${esc(b.name)}${b.members ? '<span class="pill">群組</span>' : ""}${b.unread ? '<span class="badge"></span>' : ""}</div><div class="last">${state || esc(b.last || b.description || "")}</div></div></div>`; let h = botRow(main, "hero");
}).join(""); if (others.length) h += `<div class="sec">Bot <span class="n">${others.length}</span></div><div class="list">${others.map((b) => botRow(b)).join("")}</div>`;
el.querySelectorAll(".row").forEach((r) => r.onclick = () => openChat(+r.dataset.id)); const comps = (main.computers || []);
if (comps.length && !member) {
h += `<div class="sec">電腦</div><div class="list">` + comps.map((c) => {
const [icon, kind] = KIND[c.kind] || ["monitor", ""];
const state = c.kind === "own" ? (c.online ? esc(S.box || "可以用") : "沒有 Docker") : (c.online ? "在線上" : "離線");
const on = c.kind === "own" ? c.online && !/睡|暫停|關/.test(S.box || "") : c.online;
const btn = c.kind === "own" && c.online ? `<button class="mini" data-box="1">${ic("monitor", 14)} 看畫面</button>` : (c.kind === "this" && S.mac ? `<button class="mini" data-mac="1">${ic("monitor", 14)} 看畫面</button>` : "");
return `<div class="row static"><div class="ico">${ic(icon, 18)}</div><div class="mid"><div class="name">${esc(c.name)}${c.name === main.computer ? '<span class="pill">預設</span>' : ""}</div><div class="last"><span class="${on ? "st-on" : "st-off"}"></span>${state} · ${c.person ? "你正在用的" : kind}</div></div>${btn}</div>`;
}).join("") + `</div>`;
}
if ((S.pages || []).length) {
h += `<div class="sec">頁面 <span class="n">${S.pages.length}</span></div><div class="list">` + S.pages.slice(0, 6).map((p) => `<div class="row" data-page="${p.id}"><div class="ico">${ic("file-text", 18)}</div><div class="mid"><div class="name">${esc(p.title)}</div><div class="last">${esc(p.bot ? p.bot + " · " : "")}${esc(p.updated || "")}</div></div></div>`).join("") + `</div>`;
}
const routines = S.bots.flatMap((b) => (b.routines || []).map((r) => ({...r, bot: b})));
if (routines.length) {
h += `<div class="sec">例行任務 <span class="n">${routines.length}</span></div><div class="list">` + routines.map((r) => `<div class="row" data-id="${r.bot.id}"><div class="ico">${ic("alarm-clock", 18)}</div><div class="mid"><div class="name">${esc(r.title)}${r.paused ? '<span class="pill">已暫停</span>' : ""}</div><div class="last">${esc(r.bot.name)} · ${esc(r.when || "")}</div></div></div>`).join("") + `</div>`;
}
el.innerHTML = h + `<div class="homeend"></div>`;
el.querySelectorAll("[data-id]").forEach((r) => r.onclick = () => openChat(+r.dataset.id));
el.querySelectorAll("[data-box]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); S.open = main.id; openBox(false); });
el.querySelectorAll("[data-mac]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); $("m-mac").click(); });
} }
// ---------- chat ---------- // ---------- chat ----------
function chatHead(b) {
$("chat-name").textContent = b.name || ""; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face");
$("chat-model").innerHTML = b.via ? `${esc(b.via)} ${ic("chevron-down", 12)}` : "";
}
function openChat(id) { function openChat(id) {
S.open = id; S.msgs = []; S.pending = []; renderChips(); S.open = id; S.msgs = []; S.pending = []; renderChips();
const b = S.bots.find((x) => x.id === id) || {}; const b = S.bots.find((x) => x.id === id) || {};
$("chat-name").textContent = b.name || ""; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face"); chatHead(b); S.unfold = {};
$("msgs").innerHTML = ""; show("chat"); loadChat(true); renderAsk(); $("msgs").innerHTML = ""; show("chat"); loadChat(true); renderAsk();
api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {}); api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {});
} }
@ -539,8 +674,28 @@ function md(text) {
}).join(""); }).join("");
} }
const ST = {waiting: ic("circle-alert", 14), running: ic("loader", 14), done: ic("circle-check", 14), denied: ic("minus", 14), failed: ic("circle-x", 14)}; const ST = {waiting: ic("circle-alert", 14), running: ic("loader", 14), done: ic("circle-check", 14), denied: ic("minus", 14), failed: ic("circle-x", 14)};
// What a new chat offers: the everyday jobs, one tap to start writing them.
const SUGGEST = [
["search", "海巡 Threads", "幫我在 Threads 海巡「」最近的熱門討論,整理互動最多的 5 則(誰發的、在講什麼、讚數、連結)。"],
["pen", "寫一則貼文", "用我 Threads 帳號平常的語氣,幫我寫一則關於「」的貼文草稿,先給我看,不要發。"],
["camera", "看我電腦的畫面", "截一張我電腦現在的畫面給我看。"],
["list-checks", "幫我研究一件事", "幫我研究「」:查清楚現況和來源,整理成一頁。"],
];
function stepRow(t) { return `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`; }
function renderMsgs() { function renderMsgs() {
const busy = (S.bots.find((b) => b.id === S.open) || {}).busy; const bot = S.bots.find((b) => b.id === S.open) || {};
const busy = bot.busy;
// A turn: the bot's messages between two of the person's. Its steps fold into one line once it
// is done (a long job listed dozens of them); while it works, the last few stay in view.
const turns = []; let cur = null;
S.msgs.forEach((m, i) => {
if (m.role === "user" || m.summary) { cur = null; return; }
if (!cur) { cur = {first: i, last: i, key: String(m.at || i), steps: 0}; turns.push(cur); }
cur.last = i; cur.steps += (m.tools || []).filter((t) => !t.page && !t.card).length;
});
const turnOf = {}; turns.forEach((t) => { for (let i = t.first; i <= t.last; i++) turnOf[i] = t; });
const lastTurn = turns[turns.length - 1];
let shownSteps = {};
const html = S.msgs.map((m, i) => { const html = S.msgs.map((m, i) => {
if (m.role === "user") { if (m.role === "user") {
const atts = (m.attachments || []).map(attHtml).join(""); const atts = (m.attachments || []).map(attHtml).join("");
@ -548,20 +703,110 @@ function renderMsgs() {
return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`; return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`;
} }
if (m.summary) return `<div class="m bot"><div class="via">(較早的對話已整理成摘要)</div></div>`; if (m.summary) return `<div class="m bot"><div class="via">(較早的對話已整理成摘要)</div></div>`;
const tools = (m.tools || []).map((t) => t.page ? pageCard(t.page) : t.card ? card(t.card) : `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`).join(""); const t = turnOf[i];
const running = busy && t === lastTurn;
const open = t && (S.unfold[t.key] || t.steps <= 2);
let tools = "";
for (const x of (m.tools || [])) {
if (x.page) { tools += pageCard(x.page); continue; }
if (x.card) { tools += card(x.card); continue; }
const n = (shownSteps[t.key] = (shownSteps[t.key] || 0) + 1);
if (open) { tools += stepRow(x); continue; }
// Folded: one line where the first step was; while working, the newest three still show.
if (n === 1) tools += `<div class="fold ${running ? "run" : ""}" data-turn="${esc(t.key)}">${running ? ST.running : ic("circle-check", 14)} ${running ? "已做了" : "已完成"} ${t.steps} 個步驟 ${ic("chevron-right", 13)}</div>`;
if (running && n > t.steps - 3) tools += stepRow(x);
}
const last = i === S.msgs.length - 1; const last = i === S.msgs.length - 1;
const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : ""); const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : "");
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : ""; const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
const via = m.text && m.via && !busy ? `<div class="via">${esc(m.via)}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}</div>` : ""; const end = t && i === t.last && !running;
const via = m.text && m.via && end ? `<div class="via">${esc(m.via)}${m.at ? " · " + fmtTime(m.at) : ""}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}</div>` : "";
const acts = end && m.text && !m.error ? `<div class="acts"><button data-copy="${i}" title="複製">${ic("copy", 15)}</button>${t === lastTurn && S.role !== "member" ? `<button data-regen="1" title="重新產生這個回答">${ic("refresh", 15)}</button>` : ""}</div>` : "";
const atts = (m.attachments || []).map(attHtml).join(""); const atts = (m.attachments || []).map(attHtml).join("");
return `<div class="m bot ${m.error ? "err" : ""}">${who}${body}${tools}${atts}${last ? "" : ""}${via}</div>`; return `<div class="m bot ${m.error ? "err" : ""}">${who}${body}${tools}${atts}${via}${acts}</div>`;
}).join(""); }).join("");
$("msgs").innerHTML = html || `<div class="empty">還沒有訊息,說聲嗨吧</div>`; if (!html) {
const name = esc(bot.name || "Bot");
$("msgs").innerHTML = `<div class="sugg"><div class="av face">${avatarSvg(bot.shape, bot.color)}</div><div class="hi">嗨,我是${name}</div><div class="sub">想做什麼?點一個開始,或直接打字。</div>${SUGGEST.map((x, k) => `<button data-sugg="${k}">${ic(x[0], 18)} ${esc(x[1])}</button>`).join("")}</div>`;
} else {
$("msgs").innerHTML = html;
}
$("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length $("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length
? `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>` ? `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>`; : `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>`;
loadFiles(); loadFiles();
} }
$("msgs").addEventListener("click", async (e) => {
const f = e.target.closest(".fold");
if (f) { S.unfold[f.dataset.turn] = true; renderMsgs(); return; }
const c = e.target.closest("[data-copy]");
if (c) {
const m = S.msgs[+c.dataset.copy];
try { await navigator.clipboard.writeText(m.text || ""); toast("已複製"); } catch (_) { toast("這支手機不讓網頁複製"); }
return;
}
if (e.target.closest("[data-regen]")) { try { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); toast("重新產生中…"); } catch (err) { toast(err.message); } return; }
const sg = e.target.closest("[data-sugg]");
if (sg) {
const text = SUGGEST[+sg.dataset.sugg][2];
const input = $("input"); input.value = text; input.focus();
const at = text.indexOf("「」"); if (at >= 0) input.setSelectionRange(at + 1, at + 1);
input.dispatchEvent(new Event("input"));
}
});
// ---------- the bot's settings (tap its name) ----------
// Its model, its computer, its past conversations; the same choices as the desktop's menu.
function openBotSheet() {
const b = S.bots.find((x) => x.id === S.open);
if (!b) return;
const owner = S.role !== "member";
let h = `<div class="head"><div class="av face">${avatarSvg(b.shape, b.color)}</div><div style="min-width:0"><div class="n">${esc(b.name)}</div><div class="m">${esc(b.description || "")}</div></div></div>`;
if (owner && (S.models || []).length) {
h += `<details><summary>${ic("sparkles", 18)} 模型<span class="v">${esc(b.via || "")}</span>${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}</summary>` +
S.models.map((p) => `<div class="grp">${esc(p.label)}</div>` + (p.models || []).map((m) => `<button class="opt" data-provider="${esc(p.provider)}" data-model="${esc(m)}">${esc(m)}${p.provider === b.provider && m === b.model ? `<span class="ck">${ic("check", 16)}</span>` : ""}</button>`).join("")).join("") + `</details>`;
}
if (owner && (b.computers || []).length) {
h += `<details><summary>${ic("monitor", 18)} 用哪台電腦<span class="v">${esc(b.computer || "")}</span>${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}</summary>` +
b.computers.map((c) => `<button class="opt" data-computer="${esc(c.name)}"><span class="${c.online ? "st-on" : "st-off"}"></span>${esc(c.name)}${c.name === b.computer ? `<span class="ck">${ic("check", 16)}</span>` : ""}</button>`).join("") + `</details>`;
}
if ((b.threads || []).length) {
h += `<details><summary>${ic("history", 18)} 過去的對話<span class="v">${b.threads.length} 個</span>${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}</summary>` +
b.threads.map((t) => `<button class="opt" data-thread="${t.index}">${esc(t.title || "對話")}<span class="ck" style="color:var(--text3);font-size:12px">${fmtTime(t.at)}</span></button>`).join("") + `</details>`;
}
if (owner) h += `<button class="act" data-sheet="regen">${ic("refresh", 18)} 重新產生上一個回答</button><button class="act" data-sheet="new">${ic("plus", 18)} 開始新對話</button>`;
h += `<button class="act" data-sheet="close">${ic("x", 18)} 關閉</button>`;
$("botsheet-in").innerHTML = h;
$("botsheet").classList.add("on");
}
$("b-botinfo").onclick = openBotSheet;
$("botsheet").addEventListener("click", async (e) => {
if (e.target.id === "botsheet") { $("botsheet").classList.remove("on"); return; }
const done = (msg) => { $("botsheet").classList.remove("on"); if (msg) toast(msg); };
const o = e.target.closest(".opt, .act");
if (!o) return;
try {
if (o.dataset.model) { await api(base() + `api/bots/${S.open}/set`, {json: {provider: o.dataset.provider, model: o.dataset.model}}); done("之後用 " + o.dataset.model + " 回答"); }
else if (o.dataset.computer) { await api(base() + `api/bots/${S.open}/set`, {json: {computer: o.dataset.computer}}); done("之後預設在「" + o.dataset.computer + "」做事"); }
else if (o.dataset.thread) {
if (!confirm("回到這段對話?現在的對話會留在「過去的對話」裡。")) return;
await api(base() + `api/bots/${S.open}/restore`, {json: {index: +o.dataset.thread}}); done("已回到那段對話"); S.unfold = {}; setTimeout(() => loadChat(true), 800);
}
else if (o.dataset.sheet === "regen") { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); done("重新產生中…"); }
else if (o.dataset.sheet === "new") { done(); $("b-new").click(); }
else done();
} catch (err) { toast(err.message); }
});
$("m-theme").onclick = () => {
let t = ""; try { t = localStorage.getItem("anybot.theme") || ""; } catch (_) {}
const next = THEMES[(THEMES.findIndex((x) => x[0] === t) + 1) % THEMES.length][0];
try { localStorage.setItem("anybot.theme", next); } catch (_) {}
applyTheme();
};
applyTheme();
// To the newest message, when scrolled up.
$("b-down").innerHTML = ic("arrow-down", 18);
$("scroller").addEventListener("scroll", () => { const sc = $("scroller"); $("b-down").classList.toggle("on", sc.scrollHeight - sc.scrollTop - sc.clientHeight > 400); });
$("b-down").onclick = () => { const sc = $("scroller"); sc.scrollTo({top: sc.scrollHeight, behavior: "smooth"}); };
function card(c) { function card(c) {
if (!c) return ""; if (!c) return "";
const rows = (c.rows || []).map((r) => `<div class="r"><span>${esc(r.label)}</span><span>${esc(r.value)}</span></div>`).join(""); const rows = (c.rows || []).map((r) => `<div class="r"><span>${esc(r.label)}</span><span>${esc(r.value)}</span></div>`).join("");

View File

@ -220,7 +220,16 @@ impl App {
(1u8, &l.status, l.paused).hash(&mut h); (1u8, &l.status, l.paused).hash(&mut h);
} }
self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h); self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h);
// The phone's home and bot sheet: its model, computer, routines and past chats.
(b.provider, &b.model, &b.default_computer, b.threads.len(), b.id == self.state.primary_bot).hash(&mut h);
for r in &b.routines {
(r.id, &r.title, r.paused, r.next_run).hash(&mut h);
}
} }
for (id, _, online) in self.rm.peers.lock().unwrap().iter() {
(id, online).hash(&mut h);
}
crate::cloudbox::run_state().label().hash(&mut h);
for pg in &self.state.pages { for pg in &self.state.pages {
(pg.id, pg.revision, pg.updated, &pg.title).hash(&mut h); (pg.id, pg.revision, pg.updated, &pg.title).hash(&mut h);
} }
@ -239,15 +248,38 @@ impl App {
// Pinned first, then the most recently active. // Pinned first, then the most recently active.
bots.sort_by_key(|b| (!b.pinned, -(b.last_turn.as_ref().map(|t| t.settled_at_ms).unwrap_or(0)))); bots.sort_by_key(|b| (!b.pinned, -(b.last_turn.as_ref().map(|t| t.settled_at_ms).unwrap_or(0))));
let mut view = remote::View { host: self.state.remote.host_name.clone(), ..Default::default() }; let mut view = remote::View { host: self.state.remote.host_name.clone(), ..Default::default() };
let peers = self.rm.peers.lock().unwrap().clone();
for b in bots { for b in bots {
// Its computers, each as the phone shows it: what it is and whether it is there now.
let targets = self.targets_for(b);
let roles = self.roles_for(b, &targets);
let computer = Self::chosen_computer(b, &targets, &roles).0;
let computers: Vec<serde_json::Value> = targets.iter().map(|t| {
let (kind, online) = match &t.place {
tools::Place::Box { .. } => ("own", crate::cloudbox::available()),
tools::Place::Sealed { .. } => ("own", false),
tools::Place::Local { .. } => ("this", true),
tools::Place::Device { host, .. } => ("device", peers.iter().any(|(id, _, on)| id == host && *on)),
tools::Place::Ssh { .. } => ("ssh", true),
};
json!({"name": t.name, "kind": kind, "online": online, "person": roles.person.as_deref() == Some(t.name.as_str())})
}).collect();
let live = self.streams.get(&b.id); let live = self.streams.get(&b.id);
let last = b.messages.iter().rev().find(|m| !m.text.trim().is_empty() && m.role != Role::Tool).map(|m| first_line(&m.text, 60)).unwrap_or_default(); // The last thing said (not an error the model service sent, which then sat on the home).
let last = b.messages.iter().rev().find(|m| !m.text.trim().is_empty() && m.role != Role::Tool && !m.error).map(|m| first_line(&m.text, 60)).unwrap_or_default();
let c = crate::avatar::color(&b.color); let c = crate::avatar::color(&b.color);
let hex = format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()); let hex = format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
view.bots.push(json!({ view.bots.push(json!({
"id": b.id, "name": b.name, "description": b.description, "color": hex, "shape": b.shape, "id": b.id, "name": b.name, "description": b.description, "color": hex, "shape": b.shape,
"busy": live.is_some(), "status": live.and_then(|l| l.status.clone()).unwrap_or_default(), "busy": live.is_some(), "status": live.and_then(|l| l.status.clone()).unwrap_or_default(),
"unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned, "unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned,
"primary": b.id == self.state.primary_bot,
"time": b.messages.iter().rev().find(|m| m.role != Role::Tool).map(|m| m.at).unwrap_or(0),
"via": format!("{} · {}", b.provider.label(), b.model),
"provider": serde_json::to_value(b.provider).unwrap_or_default(), "model": b.model,
"computer": computer, "computers": computers,
"routines": b.routines.iter().map(|r| json!({"id": r.id, "title": r.title, "paused": r.paused, "when": crate::appctl::routine_when(r)})).collect::<Vec<_>>(),
"threads": b.threads.iter().enumerate().rev().take(20).map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::<Vec<_>>(),
})); }));
let start = b.messages.len().saturating_sub(remote::PHONE_TAIL); 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, &self.state.pages)).collect()); view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names, &self.state.pages)).collect());
@ -256,6 +288,13 @@ impl App {
} }
} }
view.pages = self.state.pages.clone(); view.pages = self.state.pages.clone();
// The models a bot can be switched to from the phone: each provider that is ready.
view.models = crate::providers::ALL_PROVIDERS
.iter()
.filter(|&&k| !k.needs_key() || !crate::providers::resolve_key(k, &self.state.provider(k).api_key).is_empty())
// Only models that chat: an image or video generator picked for a bot would break it.
.map(|&k| json!({"provider": serde_json::to_value(k).unwrap_or_default(), "label": k.label(), "models": self.models_for(k).into_iter().filter(|m| chats(m)).collect::<Vec<_>>()}))
.collect();
remote::hub().publish(view); remote::hub().publish(view);
} }
@ -310,6 +349,20 @@ impl App {
self.start_reply(ctx, bot); self.start_reply(ctx, bot);
} }
Cmd::Stop { bot } => self.stop(bot), Cmd::Stop { bot } => self.stop(bot),
Cmd::SetBot { bot, provider, model, computer } => {
let kind = serde_json::from_value::<crate::providers::ProviderKind>(json!(provider)).ok();
if let (Some(k), false) = (kind, model.is_empty()) {
if let Some(b) = self.state.bot_mut(bot) {
b.provider = k;
b.model = model;
}
self.mark();
}
if !computer.is_empty() {
let _ = self.update_bot_computer(bot, &json!({"default_computer": computer}).to_string());
self.mark();
}
}
Cmd::Regenerate { bot } => self.regenerate(ctx, bot), Cmd::Regenerate { bot } => self.regenerate(ctx, bot),
Cmd::Reply { bot } => { Cmd::Reply { bot } => {
if !self.streams.contains_key(&bot) && self.state.bot(bot).and_then(|b| b.messages.last()).map(|m| m.role == Role::User).unwrap_or(false) { if !self.streams.contains_key(&bot) && self.state.bot(bot).and_then(|b| b.messages.last()).map(|m| m.role == Role::User).unwrap_or(false) {
@ -780,3 +833,18 @@ impl App {
.collect() .collect()
} }
} }
/// A model one can chat with (not an image, video, speech or embedding one).
fn chats(model: &str) -> bool {
let m = model.to_lowercase();
!["imagine", "image", "video", "embed", "tts", "whisper", "audio", "moderation", "dall-e", "sora", "veo"].iter().any(|k| m.contains(k))
}
#[cfg(test)]
mod phone_tests {
#[test]
fn only_chat_models_are_offered() {
assert!(super::chats("grok-4.7") && super::chats("claude-opus-5-5") && super::chats("gpt-5-mini"));
assert!(!super::chats("grok-imagine-video-1.5") && !super::chats("text-embedding-3-large") && !super::chats("gpt-4o-mini-tts"));
}
}

View File

@ -172,6 +172,18 @@ fn provider_of(key: &str) -> Option<ProviderKind> {
ALL_PROVIDERS.iter().copied().find(|p| p.key() == key) ALL_PROVIDERS.iter().copied().find(|p| p.key() == key)
} }
/// When a routine runs next, in words (for the phone's list).
pub fn routine_when(r: &Routine) -> String {
if let Some(t) = &r.trigger {
return format!("事件觸發:{}", t.describe());
}
match (r.paused, r.next_run) {
(true, _) => "已暫停".to_string(),
(false, Some(t)) => format!("下次 {}", routines::describe_when(t, &r.schedule.timezone)),
_ => "沒有下次".to_string(),
}
}
pub fn routine_line(r: &Routine) -> String { pub fn routine_line(r: &Routine) -> String {
let next = match (r.paused, r.next_run) { let next = match (r.paused, r.next_run) {
(true, _) => "已暫停".to_string(), (true, _) => "已暫停".to_string(),

View File

@ -121,6 +121,8 @@ pub enum Cmd {
Answer { bot: u64, answer: String }, Answer { bot: u64, answer: String },
Read { bot: u64 }, Read { bot: u64 },
NewThread { bot: u64 }, NewThread { bot: u64 },
/// The phone changes a bot's model or its computer (empty = unchanged).
SetBot { bot: u64, provider: String, model: String, computer: String },
/// Answer the last message again (重新產生) / reply to what is there (重新傳送). /// Answer the last message again (重新產生) / reply to what is there (重新傳送).
Regenerate { bot: u64 }, Regenerate { bot: u64 },
Reply { bot: u64 }, Reply { bot: u64 },
@ -142,6 +144,8 @@ pub struct View {
pub messages: HashMap<u64, Vec<Value>>, pub messages: HashMap<u64, Vec<Value>>,
/// 頁面, for the phone's page view. /// 頁面, for the phone's page view.
pub pages: Vec<crate::pages::Page>, pub pages: Vec<crate::pages::Page>,
/// The models the phone can switch a bot to: each ready provider with its known models.
pub models: Vec<Value>,
pub version: u64, pub version: u64,
} }
@ -299,6 +303,7 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pag
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() }, "author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
"reactions": m.reactions, "reactions": m.reactions,
"attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::<Vec<_>>(), "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::<Vec<_>>(),
"at": m.at,
"tools": calls, "tools": calls,
})) }))
} }
@ -350,12 +355,24 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
("GET", ["api", "state"]) => { ("GET", ["api", "state"]) => {
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS }); let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
// 頁面 for the phone's home: the newest first, each with its bot.
let now = crate::model::now_ms();
let mut pages: Vec<&crate::pages::Page> = v.pages.iter().collect();
pages.sort_by_key(|p| std::cmp::Reverse(p.updated));
let bot_name = |id: u64| v.bots.iter().find(|b| b["id"].as_u64() == Some(id)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string();
let pages: Vec<Value> = pages.into_iter().take(12).map(|p| {
let mut c = crate::pages::card_json(p, now);
c["bot"] = json!(bot_name(p.bot));
c
}).collect();
Resp::json(&json!({ Resp::json(&json!({
"version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals, "version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals,
"box": crate::cloudbox::run_state().label(), "box": crate::cloudbox::run_state().label(),
"box_available": crate::cloudbox::available(), "box_available": crate::cloudbox::available(),
"mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member", "mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member",
"role": role, "role": role,
"pages": pages,
"models": if role == "member" { vec![] } else { v.models.clone() },
})) }))
} }
// 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG. // 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG.
@ -411,6 +428,14 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
"read" => Cmd::Read { bot }, "read" => Cmd::Read { bot },
"new" => Cmd::NewThread { bot }, "new" => Cmd::NewThread { bot },
"regenerate" => Cmd::Regenerate { bot },
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
"set" => Cmd::SetBot {
bot,
provider: b["provider"].as_str().unwrap_or("").to_string(),
model: b["model"].as_str().unwrap_or("").trim().to_string(),
computer: b["computer"].as_str().unwrap_or("").trim().to_string(),
},
"upload" => return upload(bot, &req), "upload" => return upload(bot, &req),
_ => return Resp::error(404, "沒有這個動作"), _ => return Resp::error(404, "沒有這個動作"),
}; };