This commit is contained in:
王性驊 2026-09-12 17:36:36 +08:00
parent 1fd914550f
commit 2e1a90cdae
52 changed files with 3747 additions and 410 deletions

View File

@ -19,7 +19,7 @@ $(if $(filter 1,$(PUSH)),$(eval MULTI_FLAGS := --push))
cua-smoke prepare-screen-network \
build build-api build-supervisor build-controld \
fmt fmt-check clippy lint audit test clean \
web \
web web-dev web-watch \
dev dev-supervisor dev-api
help: ## Show this help
@ -59,10 +59,12 @@ help: ## Show this help
@echo " make test cargo test --workspace (DB tests need: make postgres)"
@echo " make test-agent-computer Screenshot / locator / native-file contract tests"
@echo " make bench-agent-computer Disposable shared/private native benchmark"
@echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)"
@echo " make web Production build of the frontend (needs node/npm)"
@echo " make web-dev Vite HMR at :5173 — edit CSS/React without rebuilding API"
@echo " make web-watch Rebuild dist on save; pair with docker-compose.web.yml on :3101"
@echo " make clean cargo clean"
@echo ""
@echo " After 'make up': open http://127.0.0.1:3101, register an account, and add a model API key in 設定 → 模型."
@echo " After 'make up': open http://127.0.0.1:3101. To iterate on the UI: make web-dev → http://127.0.0.1:5173"
# --- Environment -----------------------------------------------------------
@ -81,6 +83,7 @@ up: env ## Build every image and start the whole stack in Docker
$(COMPOSE) up -d --build
@echo ""
@echo "stack launched. open http://127.0.0.1:3101 and register your account."
@echo "frontend iteration: make web-dev (http://127.0.0.1:5173, no image rebuild)"
$(COMPOSE) ps
# Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or
@ -265,9 +268,18 @@ test-agent-computer: ## Agent-computer contract tests (screenshot, locators, nat
bench-agent-computer: ## Disposable shared/private native benchmark (no model or viewer claim)
python3 scripts/bench-agent-computer.py
web: ## Build the frontend (needs node/npm)
web: ## Production build of the frontend (needs node/npm)
cd $(WEB_DIR) && npm install && npm run build
web-dev: ## Vite HMR at http://127.0.0.1:5173; proxies /api and /view to :3101
@echo "open http://127.0.0.1:5173 (API must already be on :3101; saving a file hot-reloads)"
cd $(WEB_DIR) && npm install && npm run dev
web-watch: ## Rebuild apps/web/dist on save (for a Docker API that mounts dist)
@echo "writing $(WEB_DIR)/dist on each save. mount it with:"
@echo " docker compose -f docker-compose.yml -f docker-compose.web.yml up -d api"
cd $(WEB_DIR) && npm install && npm run watch
clean: ## Remove cargo build artifacts
cargo clean

View File

@ -97,15 +97,13 @@ make dev-supervisor # terminal 1
make dev-api # terminal 2
```
Frontend hot reload in another terminal:
Iterate on the UI without rebuilding the API image:
```bash
cd apps/web
npm install
npm run dev
make web-dev
```
Open [http://127.0.0.1:5173](http://127.0.0.1:5173). Tests and the tree layout are in the [development guide](./docs/development.md).
Open [http://127.0.0.1:5173](http://127.0.0.1:5173) (`/api` is proxied to `:3101`). Tests and the tree layout are in the [development guide](./docs/development.md).
## Docs

View File

@ -97,15 +97,13 @@ make dev-supervisor # 終端 1
make dev-api # 終端 2
```
前端熱更新使用另一個終端
改前端請用熱更新,不必重建 API 映像
```bash
cd apps/web
npm install
npm run dev
make web-dev
```
開啟 [http://127.0.0.1:5173](http://127.0.0.1:5173)。測試指令與專案目錄說明請見 [開發指南](./docs/development.md)。
開啟 [http://127.0.0.1:5173](http://127.0.0.1:5173)`/api` 會轉到已在跑的 `:3101`。測試指令與專案目錄說明請見 [開發指南](./docs/development.md)。
## 文件

View File

@ -4,7 +4,8 @@
"version": "0.1.0-alpha",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev": "vite --host 0.0.0.0 --port 5173 --strictPort",
"watch": "vite build --watch",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit --pretty false"
},

File diff suppressed because one or more lines are too long

View File

@ -40,6 +40,7 @@ function animatedIcon(animation:Animation,defaultSize=16,reverse=false){
export const BotIcon=animatedIcon(userPlus,18);
export const Brain=animatedIcon(activity,17);
export const ChevronDown=animatedIcon(arrowDown,14);
export const ChevronsLeft=animatedIcon(arrowRightCircle,17,true);
export const ChevronsRight=animatedIcon(arrowRightCircle,17);
export const CircleHelp=animatedIcon(help,16);
export const ClipboardPaste=animatedIcon(download,17);

View File

@ -9,7 +9,14 @@
.avatar-editor{display:grid;justify-items:center;gap:5px;padding:18px 0 2px}
.avatar-editor strong{margin-top:7px}
.avatar-editor .avatar.blobatar{margin-bottom:2px}
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side);border-radius:50%;background:var(--online);box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side);border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,.4);cursor:default}
/* Only a renewed worker lease earns the pulsing green dot. */
.avatar .presence.is-working{background:var(--online);animation:presence-pulse 1.8s ease-out infinite}
/* Paused on a question: steady, so it reads as "your move", not "in flight". */
.avatar .presence.is-waiting{background:var(--presence-waiting)}
/* Claims to be running with no heartbeat behind it: hollow, never alive. */
.avatar .presence.is-stalled{background:transparent;box-shadow:inset 0 0 0 2px var(--presence-stalled),0 0 0 1px rgba(0,0,0,.4)}
@keyframes presence-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--online) 55%,transparent),0 0 0 1px rgba(0,0,0,.4)}70%{box-shadow:0 0 0 6px transparent,0 0 0 1px rgba(0,0,0,.4)}100%{box-shadow:0 0 0 0 transparent,0 0 0 1px rgba(0,0,0,.4)}}
.bot-row .avatar-wrap::after{content:none;position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:var(--online);box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
.avatar.blobatar.thinking{animation:avatar-rainbow-glow 1.8s linear infinite}
.avatar-wrap{transition:transform .16s ease}

View File

@ -18,7 +18,15 @@ import {
type Expression,
} from "blobatar/expression";
import { createContext, useContext, type CSSProperties, type ReactNode } from "react";
import type { AvatarShape, RoomMember } from "./types";
import { t } from "./i18n";
import type { AvatarShape, BotPresence, RoomMember } from "./types";
/** Hover text for the dot, so the state is readable and not just a colour. */
const PRESENCE_LABEL = {
working: "presenceWorking",
waiting: "presenceWaiting",
stalled: "presenceStalled",
} as const satisfies Record<Exclude<BotPresence, "idle">, string>;
export const BLOBATAR_SHAPES = [
"round",
@ -210,6 +218,7 @@ export function Avatar({
active = false,
thinking = false,
online = false,
presence,
size = 32,
lookId,
expression,
@ -221,13 +230,16 @@ export function Avatar({
shape?: AvatarShape;
active?: boolean;
thinking?: boolean;
/** Shorthand for `presence="working"`, kept for callers with a plain flag. */
online?: boolean;
presence?: BotPresence;
size?: number;
lookId?: string;
expression?: AvatarExpression;
background?: AvatarBackground;
gaze?: boolean;
}) {
const dot: BotPresence = presence || (online ? "working" : "idle");
const stored = useContext(AvatarLookContext)[lookId || ""] || DEFAULT_LOOK;
const resolved = color || fallbackColor(name);
const silhouette = resolveBlobatarShape(shape);
@ -251,7 +263,7 @@ export function Avatar({
palette={{ head: resolved, eye: contrastEye(resolved) }}
title={name}
/>
{(online || active) && <i className="presence" aria-hidden="true" />}
{dot !== "idle" && <i className={`presence is-${dot}`} title={t(PRESENCE_LABEL[dot])} />}
</span>
);
}
@ -261,12 +273,22 @@ export function AvatarStack({
size = 38,
online = false,
thinkingIds,
workingIds,
presenceById,
}: {
members: RoomMember[];
size?: number;
online?: boolean;
thinkingIds?: string[];
workingIds?: string[];
presenceById?: Record<string, BotPresence>;
}) {
const stateFor = (id: string, fallback: boolean): BotPresence => {
const known = presenceById?.[id];
if (known && known !== "idle") return known;
if (workingIds?.includes(id)) return "working";
return fallback ? "working" : "idle";
};
if (members.length === 1) {
const member = members[0];
return (
@ -277,7 +299,7 @@ export function AvatarStack({
shape={member.avatarShape}
size={size}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={online}
presence={stateFor(member.id, online && !workingIds && !presenceById)}
/>
);
}
@ -302,7 +324,10 @@ export function AvatarStack({
shape={member.avatarShape}
size={miniSize}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={Boolean(online && index === shown.length - 1)}
presence={stateFor(
member.id,
online && !workingIds && !presenceById && index === shown.length - 1,
)}
/>
</span>
))}

View File

@ -164,7 +164,7 @@ export function CallOverlay({
<div className="call-overlay" role="dialog" aria-label={t("call")}>
<div className="call-card" data-testid="call-view">
<div className="call-kicker">{t("call")}</div>
<Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape as AvatarShape} active online size={72} />
<Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape as AvatarShape} active online={phase==="working"} size={72} />
<strong className="call-name">{bot.name}</strong>
<div className={`call-phase ${phase}`}>{phaseLabel(phase, computer)}</div>
<p className={`call-caption ${caption ? caption.role : ""}`}>{caption?.text || t("callPrompt")}</p>

View File

@ -1,4 +1,4 @@
.messages{flex:1;overflow:auto;padding:34px max(34px,7vw) 150px;display:flex;flex-direction:column;gap:18px}
.messages{flex:1;overflow:auto;overflow-x:hidden;padding:34px max(34px,7vw) 150px;display:flex;flex-direction:column;gap:18px}
.message{display:flex}
.message>.message-body,.message-stack>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
@ -6,7 +6,7 @@
.message.user>.message-body{background:var(--cream);color:var(--on-cream)}
.message.assistant>.message-body,.message-stack>.message-body{background:#19191c}
.message-stack{display:flex;flex-direction:column;align-items:flex-start;max-width:76%;min-width:0}
.message-stack>.message-body{max-width:100%}
.message-stack>.message-body{max-width:100%;min-width:0}
.copy-msg{display:inline-flex;align-items:center;gap:6px;height:28px;margin-top:6px;padding:0 8px 0 6px;border:0;border-radius:8px;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
.copy-msg svg{flex:0 0 14px}
.copy-msg:hover{background:rgba(255,255,255,.07);color:var(--ink)}
@ -16,9 +16,9 @@
.composer-plus{background:transparent;color:var(--muted)}
.composer svg{width:18px}
.messages{min-width:0;padding-bottom:24px;padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
.message{box-sizing:border-box;width:var(--chat-col);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
.message{box-sizing:border-box;width:var(--chat-col);max-width:none;min-width:0;margin-inline:auto;overflow-wrap:anywhere}
.message>span{max-width:82%}
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);margin:0}
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);min-width:0;margin:0}
.thinking-dots{position:relative;display:flex;align-items:center;gap:4px;height:32px;padding:0 12px;border:1px solid var(--border);border-radius:14px;background:var(--surface);overflow:hidden}
.thinking-dots:after{content:"";position:absolute;left:12px;bottom:4px;width:24px;height:2px;border-radius:999px;background:linear-gradient(90deg,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff73dc,#7c5cff);background-size:200% 100%;animation:small-magic-line 1.25s linear infinite}
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}
@ -30,7 +30,7 @@
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-width:0;max-width:100%;min-height:62px;align-items:center;padding:9px 10px;transform:none}
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
.slash-suggestions{position:absolute;left:58px;right:58px;bottom:calc(100% + 8px);z-index:8;display:grid;gap:2px;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35)}
.slash-suggestions{position:absolute;left:58px;right:58px;bottom:calc(100% + 8px);z-index:8;display:grid;gap:2px;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35);min-width:0;max-width:100%}
.slash-suggestions button{display:flex;align-items:baseline;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:9px;background:transparent;color:var(--ink);text-align:left;cursor:pointer}
.slash-suggestions button:hover,.slash-suggestions button:focus-visible{background:rgba(255,255,255,.08);outline:0}
.slash-suggestions strong{min-width:110px;color:var(--accent);font-weight:650}
@ -54,7 +54,7 @@
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
.message{position:relative;padding-right:0;-webkit-touch-callout:none;touch-action:manipulation}
.messages .remember-msg{display:none}
.working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
.message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal}
.message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
@ -69,6 +69,14 @@
.message.assistant.spoken .speaker{padding:0 2px;letter-spacing:.01em}
.message.assistant.spoken>.message-body,.message.assistant.spoken>.message-stack{grid-column:2;max-width:82%;min-width:0}
.message.assistant.spoken .message-stack>.message-body{max-width:100%;line-height:1.52;border-radius:6px 18px 18px 18px}
.message.assistant.spoken .msg-attachments,
.message.assistant.spoken>.login-chip,
.message.assistant.spoken>.sched-chip,
.message.assistant.spoken>.message-time{grid-column:2;max-width:100%;min-width:0}
.message.assistant.spoken .msg-avatar{grid-row:2/-1}
.message.assistant.spoken{padding-bottom:0;align-items:start}
.message.assistant.spoken .message-time{position:static;left:auto;right:auto;margin-top:2px}
.message.assistant.spoken .msg-attachments{justify-content:flex-start}
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}
.slash-suggestions button[aria-selected="true"]{background:rgba(255,255,255,.08)}
@ -127,3 +135,24 @@
.host-menu button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.host-menu-label{padding:2px 9px 4px;color:var(--muted);font-size:11px}
.host-flag{margin-left:auto;color:var(--accent);font-size:11px;font-weight:650}
.run-wait-status{padding:6px 20px;color:var(--muted);font-size:12px;flex-shrink:0}
/* Task ownership remains visible without a stream of tool narration. */
.task-status-list { flex:0 0 auto; max-height:28vh; overflow:auto; border-bottom:1px solid var(--line); }
.task-status { padding:10px 18px; font-size:12px; }
.task-status-heading { display:flex; align-items:center; gap:8px; min-width:0; }
.task-status-heading strong { white-space:nowrap; }
.task-summary { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); flex:1; }
.task-probe { color:var(--muted); cursor:pointer; white-space:nowrap; }
.task-dot { width:7px; height:7px; border-radius:50%; background:#8b8b92; flex:none; }
.task-dot.taskWorking,.task-dot.taskRecovering { background:#21a56a; }
.task-dot.taskWaiting,.task-dot.needTaskInput { background:#c58b22; }
.task-dot.taskDisconnected,.task-dot.taskFailed { background:#c85b56; }
.task-status details { margin-top:5px; color:var(--muted); }
.task-status p { margin:6px 0; line-height:1.5; }
.advanced-settings { margin:12px 0; width:100%; }
.advanced-settings > summary { padding:8px 0; cursor:pointer; color:var(--muted); font-size:13px; }
.advanced-settings[open] > summary { margin-bottom:8px; }
.assistant-settings-nav { display:flex; gap:6px; flex-wrap:wrap; padding:12px 14px 0; }
.results-cards { display:grid; gap:10px; padding:12px; }
@media(max-width:700px) { .task-status{padding:8px 12px;} .task-probe{font-size:11px;} .task-status-list{max-height:22vh;} }

View File

@ -0,0 +1,47 @@
.dialog.file-preview-dialog{
width:min(840px,calc(100vw - 28px));
max-height:min(860px,calc(100dvh - 28px));
grid-template-rows:auto minmax(0,1fr);
overflow:hidden
}
.file-preview-dialog .dialog-title{gap:12px}
.file-preview-dialog .dialog-title h2{
min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:16px
}
.file-preview-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}
.file-preview-status{margin:0;color:var(--muted)}
.file-preview-image{
display:block;max-width:100%;max-height:min(70vh,640px);margin:0 auto;
object-fit:contain;border-radius:12px;background:var(--inset)
}
.file-preview-markdown,.file-preview-text,.file-preview-frame{
min-height:0;max-height:min(70vh,640px);overflow:auto;
border:1px solid var(--border);border-radius:12px;background:var(--inset)
}
.file-preview-markdown{padding:14px 16px}
.file-preview-text{
margin:0;padding:14px 16px;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere
}
.file-preview-frame{width:100%;height:min(70vh,640px);border:0;background:#fff}
.computer-files{display:grid;gap:8px;min-width:0}
.computer-files-head{display:flex;align-items:center;gap:8px;color:var(--ink);font-weight:600}
.computer-files-head>span{margin-right:auto}
.computer-files-head .outline{min-height:30px;padding:0 10px;font-size:12px}
.computer-files-empty{margin:0;color:var(--muted);font-size:12px;line-height:1.45}
.computer-files-list{display:grid;gap:4px;margin:0;padding:0;list-style:none;max-height:180px;overflow:auto}
.computer-file-row{
display:flex;align-items:baseline;gap:10px;width:100%;padding:8px 10px;
border:1px solid var(--border);border-radius:10px;background:var(--inset);
color:var(--ink);text-align:left;cursor:pointer
}
.computer-file-row:hover{background:var(--surface-hover)}
.computer-file-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600}
.computer-file-row small{margin-left:auto;flex-shrink:0;color:var(--muted);font-size:11px}
.file-card.is-openable:hover{border-color:var(--accent);background:var(--surface-hover)}
.file-card-open{
display:flex;align-items:center;gap:8px;min-width:0;flex:1;margin:0;padding:0;
border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer
}

View File

@ -0,0 +1,172 @@
import { useEffect, useMemo, useState } from "react";
import { ApiError } from "./api";
import { t } from "./i18n";
import { ChatMarkdown } from "./markdown";
import { X } from "./animated-icons";
import "./file-preview.css";
export type PreviewTarget = {botId:string;path:string;name?:string};
type WorkspaceEntry = {path:string;name:string;kind:string;size:number};
function fileName(path:string){return path.split("/").filter(Boolean).pop()||path}
function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`}
function parentPath(path:string,root:string){
if(!path||path===root)return "";
const cut=path.lastIndexOf("/");
const parent=cut<0?"":path.slice(0,cut);
if(root&&(parent===root||parent.startsWith(`${root}/`)))return parent;
return "";
}
function previewKind(name:string,contentType:string){
const ext=(name.split(".").pop()||"").toLowerCase();
if(contentType.startsWith("image/")||["png","jpg","jpeg","gif","webp","svg"].includes(ext))return "image";
if(contentType.includes("markdown")||ext==="md"||ext==="markdown")return "markdown";
if(contentType==="application/pdf"||ext==="pdf")return "pdf";
if(contentType.startsWith("text/")||contentType.includes("json")||["txt","csv","json","xml","yaml","yml","log","toml","rs","ts","tsx","js","py","css","sh","html","htm"].includes(ext))return "text";
return "binary";
}
async function readWorkspaceFile(botId:string,path:string){
const response=await fetch(`/api/bots/${encodeURIComponent(botId)}/workspace/file?path=${encodeURIComponent(path)}`,{credentials:"same-origin"});
const contentType=response.headers.get("content-type")||"application/octet-stream";
if(!response.ok){
const raw=await response.text();
let message=raw;
try{const body=JSON.parse(raw) as {message?:string};if(body.message)message=body.message}catch{/* keep raw */}
throw new ApiError(response.status,message||`${response.status} ${response.statusText}`);
}
return {blob:await response.blob(),contentType};
}
export function FilePreviewDialog({target,close}:{target:PreviewTarget;close:()=>void}){
const name=target.name||fileName(target.path);
const [status,setStatus]=useState<"loading"|"ready"|"error">("loading");
const [error,setError]=useState("");
const [contentType,setContentType]=useState("");
const [objectUrl,setObjectUrl]=useState<string|null>(null);
const [text,setText]=useState("");
const kind=previewKind(name,contentType);
useEffect(()=>{
const onKey=(event:KeyboardEvent)=>{if(event.key==="Escape")close()};
window.addEventListener("keydown",onKey);
return ()=>window.removeEventListener("keydown",onKey);
},[close]);
useEffect(()=>{
let cancelled=false;
let url:string|null=null;
setStatus("loading");setError("");setText("");setContentType("");setObjectUrl(null);
readWorkspaceFile(target.botId,target.path).then(async result=>{
if(cancelled)return;
setContentType(result.contentType);
const nextKind=previewKind(name,result.contentType);
if(nextKind==="image"||nextKind==="pdf"||nextKind==="binary"){
url=URL.createObjectURL(result.blob);
if(cancelled){URL.revokeObjectURL(url);url=null;return}
setObjectUrl(url);
}else{
const raw=await result.blob.text();
if(cancelled)return;
if(nextKind==="text"&&(result.contentType.includes("json")||name.toLowerCase().endsWith(".json"))){
try{setText(JSON.stringify(JSON.parse(raw),null,2))}catch{setText(raw)}
}else setText(raw);
}
if(!cancelled)setStatus("ready");
}).catch(error=>{
if(cancelled)return;
const statusCode=error instanceof ApiError?error.status:0;
setError(statusCode===409?t("filePreviewUnavailable"):statusCode===413?t("filePreviewTooLarge"):statusCode===400?t("filePreviewInvalid"):t("filePreviewFailed"));
setStatus("error");
});
return ()=>{cancelled=true;if(url)URL.revokeObjectURL(url)};
},[target.botId,target.path,name]);
function download(){
if(!objectUrl&&!text)return;
const link=document.createElement("a");
if(objectUrl)link.href=objectUrl;
else link.href=URL.createObjectURL(new Blob([text],{type:contentType||"text/plain"}));
link.download=name;
link.click();
if(!objectUrl)URL.revokeObjectURL(link.href);
}
return <div className="modal-backdrop" onClick={close}>
<div className="dialog file-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="file-preview-title" onClick={event=>event.stopPropagation()}>
<div className="dialog-title">
<h2 id="file-preview-title">{name}</h2>
<div className="file-preview-actions">
<button type="button" className="outline" disabled={status!=="ready"} onClick={download}>{t("downloadFile")}</button>
<button type="button" onClick={close} aria-label={t("close")}><X/></button>
</div>
</div>
{status==="loading"&&<p className="file-preview-status">{t("filePreviewLoading")}</p>}
{status==="error"&&<p className="file-preview-status" role="alert">{error}</p>}
{status==="ready"&&kind==="image"&&objectUrl&&<img className="file-preview-image" src={objectUrl} alt={name}/>}
{status==="ready"&&kind==="markdown"&&<div className="file-preview-markdown"><ChatMarkdown>{text}</ChatMarkdown></div>}
{status==="ready"&&kind==="text"&&<pre className="file-preview-text">{text}</pre>}
{status==="ready"&&kind==="pdf"&&objectUrl&&<iframe className="file-preview-frame" title={name} src={objectUrl} sandbox=""/>}
{status==="ready"&&kind==="binary"&&<p className="file-preview-status">{t("filePreviewBinary")}</p>}
</div>
</div>;
}
export function ComputerFilesList({botId,running,onOpen}:{botId:string;running:boolean;onOpen:(path:string,name:string)=>void}){
const [cwd,setCwd]=useState("");
const [root,setRoot]=useState("");
const [entries,setEntries]=useState<WorkspaceEntry[]>([]);
const [error,setError]=useState<string|null>(null);
const [loading,setLoading]=useState(false);
const canUp=useMemo(()=>Boolean(cwd)&&cwd!==root,[cwd,root]);
useEffect(()=>{setCwd("");setRoot("");setEntries([])},[botId]);
useEffect(()=>{
if(!running){setEntries([]);setError(null);setLoading(false);return}
let cancelled=false;
setLoading(true);setError(null);
const query=cwd?`?path=${encodeURIComponent(cwd)}`:"";
fetch(`/api/bots/${encodeURIComponent(botId)}/workspace${query}`,{credentials:"same-origin"})
.then(async response=>{
const body=await response.json().catch(()=>({})) as {message?:string;path?:string;entries?:WorkspaceEntry[]};
if(!response.ok)throw new ApiError(response.status,body.message||response.statusText);
return body;
})
.then(body=>{
if(cancelled)return;
if(!cwd&&body.path)setRoot(body.path);
setEntries(Array.isArray(body.entries)?body.entries:[]);
})
.catch(error=>{
if(cancelled)return;
const status=error instanceof ApiError?error.status:0;
setError(status===409?t("computerFilesNeedBoot"):t("computerFilesLoadFailed"));
setEntries([]);
})
.finally(()=>{if(!cancelled)setLoading(false)});
return ()=>{cancelled=true};
},[botId,running,cwd]);
return <div className="computer-files">
<div className="computer-files-head">
<span>{t("computerFiles")}</span>
{canUp&&<button type="button" className="outline" onClick={()=>setCwd(parentPath(cwd,root))}>{t("folderUp")}</button>}
</div>
{!running&&<p className="computer-files-empty">{t("computerFilesNeedBoot")}</p>}
{running&&error&&<p className="computer-files-empty" role="alert">{error}</p>}
{running&&!error&&loading&&entries.length===0&&<p className="computer-files-empty">{t("filePreviewLoading")}</p>}
{running&&!error&&!loading&&entries.length===0&&<p className="computer-files-empty">{t("computerFilesEmpty")}</p>}
{running&&entries.length>0&&<ul className="computer-files-list">
{entries.map(entry=>{
const dir=entry.kind==="dir";
return <li key={entry.path}>
<button type="button" className="computer-file-row" onClick={()=>dir?setCwd(entry.path):onOpen(entry.path,entry.name)}>
<strong>{entry.name}</strong>
<small>{dir?t("folderLabel"):formatBytes(entry.size)}</small>
</button>
</li>;
})}
</ul>}
</div>;
}

View File

@ -13,7 +13,9 @@ export type SessionEventKind =
| "run.failed"
| "run.cancelled"
| "run.completed"
| "session.cleared";
| "session.cleared"
| "tool.started"
| "reply.progress";
/**
* Kinds the app listens for. An SSE source cannot subscribe to "everything", so
@ -29,6 +31,8 @@ export const SESSION_EVENT_TYPES: SessionEventKind[] = [
"run.cancelled",
"run.completed",
"session.cleared",
"tool.started",
"reply.progress",
];
export interface SessionEvent {

View File

@ -2,6 +2,32 @@ import type { zhTW } from "./zh-TW";
/** English UI copy. Keys must stay in lockstep with zh-TW. */
export const en: { [K in keyof typeof zhTW]: string } = {
taskEnded: "Ended (delivery unverified)",
taskWaiting: "Your help is needed",
taskDetails: "Execution details",
taskDisconnected: "Connection lost",
taskStopped: "Stopped",
taskFailed: "Unfinished",
taskCompleted: "Completed",
taskRecovering: "Recovering",
taskWorking: "Working",
taskQueued: "Queued",
needTaskInput: "Waiting for your input",
loginNeedsYou: "Sign in to continue",
running: "Computer is on",
privateComputerHint: "Separate environment; uses additional computer resources",
noMcp: "No services connected. Add one when you need it.",
chooseMcp: "Connect a service",
connectMcpServer: "Connect a service",
plugins: "Connected services",
authorizationNeedsYou: "Your authorization is needed",
verificationNeedsYou: "Complete verification",
servicesHelp: "Connect the services your assistant needs to do the work.",
memoryDiagnostics: "Memory diagnostics",
workInstructionsHint: "Describe its responsibilities, preferences, and what counts as done.",
workInstructions: "Working instructions",
results: "Results",
advancedSettings: "Advanced settings",
sharedDesktop: "Shared control",
sharedNeedsUser: "{name} is waiting for you to finish the steps on screen. Then press “Done, continue”.",
doneContinue: "Done, continue",
@ -10,7 +36,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
privateComputer: "Private computer",
stopped: "Stopped",
booting: "Starting",
running: "Running",
suspended: "Sleeping",
error: "Error",
openComputer: "Start computer",
@ -33,7 +58,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
workTools: "Work tools",
computer: "Computer",
memory: "Memory",
plugins: "Plugins",
pluginShort: "Plugin",
botSettings: "Bot settings",
add: "Add",
@ -45,7 +69,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
hideHiddenItems: "Hide hidden items",
showHiddenItems: "Show hidden items",
mcpConnected: "{count} MCP connected",
connectMcpServer: "Connect an MCP server",
openOnPhone: "Open on phone",
settings: "Settings",
about: "About",
@ -148,6 +171,8 @@ export const en: { [K in keyof typeof zhTW]: string } = {
importSkillHint: "Add a skill from a previously exported JSON file",
skillImportInvalid: "That isnt a valid skill file. Choose a JSON export from LazyBoy.",
collapseSidebar: "Collapse sidebar",
collapseNav: "Collapse conversation list",
expandNav: "Show conversation list",
botComputer: "{name}s computer",
dedicatedScreen: "This screen",
enlarge: "Enlarge",
@ -293,7 +318,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
headers: "Headers KEY=value",
connecting: "Connecting…",
connectMcp: "Connect MCP",
chooseMcp: "Choose MCP",
mcpPickerTitle: "Choose MCP",
mcpPickerLead: "Pick a featured or registry server. Every agent can use its tools.",
searchMcp: "Search MCP",
@ -306,7 +330,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
mcpBackToList: "Back to list",
mcpConnectNamed: "Connect {name}",
mcpKeyHint: "This MCP needs credentials.",
noMcp: "No MCP yet. Press “Choose MCP” above to connect one from the catalog.",
toolsCount: "{count} tools",
disabled: "Off",
disconnected: "Disconnected",
@ -333,7 +356,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
pasteIntoVnc: "Paste into VNC",
botNamePlaceholder: "For example: research assistant",
sharedComputerHint: "Shares the environment with other bots",
privateComputerHint: "A fresh dedicated Docker desktop",
create: "Create",
groupDescription: "A group opens one chat where every selected agent speaks.",
groupHostHint: "{name} starts as host — one tap in the top bar swaps it.",
@ -390,7 +412,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
meetingMode: "Meeting mode",
exitMeetingMode: "Exit meeting mode",
helpMeetingTitle: "Meeting mode",
helpMeeting: "On desktop, the title bar can put the screen in the middle and chat on the side, like a shared-screen call. Phones dont have this mode.",
helpMeeting: "On desktop, the title bar can put the screen in the middle and chat on the side, like a shared-screen call. Collapse the conversation list to give chat more room. Phones dont have this mode.",
helpMemoryTitle: "Memory",
helpMemory: "Clearing a chat doesnt wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.",
helpMcpTitle: "MCP plugins",
@ -482,7 +504,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
accountNotesPlaceholder: "Optional, for example: work inbox",
addAccount: "Add account",
noAccounts: "No saved accounts yet. Add one here if a schedule should sign in by itself.",
loginNeedsYou: "Needs you to sign in on the keyboard",
loginOpenScreen: "Open its screen",
loginWhy: "After sign-in: {why}",
scheduleChip: "Scheduled",
@ -549,13 +570,20 @@ export const en: { [K in keyof typeof zhTW]: string } = {
schedCalendar: "Calendar schedule: {expr}",
teachInProgress: "A demo is in progress. Finish or cancel it before sending a message.",
aiTimeout: "The model timed out (150 seconds).",
presenceWorking: "Working on it",
presenceWaiting: "Waiting on you",
presenceStalled: "Lost its heartbeat, being picked back up",
resumeMidTask: "Stopped halfway — your call",
resumeBudget: "Turn budget spent, result unverified",
resumeLoop: "Kept repeating itself — needs a nudge",
resumePlan: "Here's the plan",
resumeProgress: "{turns}/{limit} turns",
resumeContinue: "Keep going",
resumeStart: "Start",
resumeStop: "Stop here",
resumeSent: "Keep going and finish it.", monitorTitle: "Live log",
resumeSent: "Keep going and finish it.",
resumePlanSent: "Go ahead.",
monitorTitle: "Live log",
monitorQueued: "Queued",
monitorTurnOf: "turn {turn}/{limit}",
monitorTurn: "turn {turn}",
@ -626,4 +654,19 @@ export const en: { [K in keyof typeof zhTW]: string } = {
fileEvidenceUnavailable: "This operation is unavailable on the currently assigned Computer.",
fileEvidenceStale: "The Computer changed during the check. Check again.",
fileEvidenceLoadFailed: "Could not check the file result. Try again.",
previewFile: "Preview",
downloadFile: "Download",
openFile: "Open {name}",
computerFiles: "Files",
computerFilesEmpty: "This folder is empty",
computerFilesNeedBoot: "Start the computer to browse files",
computerFilesLoadFailed: "Couldnt list files",
filePreviewLoading: "Opening file…",
filePreviewFailed: "Couldnt open this file",
filePreviewTooLarge: "This file is over 8 MB, so it cant be previewed here",
filePreviewUnavailable: "The computer isnt running, so the file cant be opened",
filePreviewInvalid: "That path cant be opened",
filePreviewBinary: "This file cant be previewed in the page. Download it instead.",
folderUp: "Up",
folderLabel: "Folder",
};

View File

@ -1,19 +1,44 @@
/** Traditional Chinese UI copy. Keep keys stable when adding another locale. */
export const zhTW = {
taskEnded: "已結束(未驗證交付)",
taskWaiting: "等你處理",
taskDetails: "執行詳情",
taskDisconnected: "連線異常",
taskStopped: "已停止",
taskFailed: "尚未完成",
taskCompleted: "已完成",
taskRecovering: "正在恢復",
taskWorking: "處理中",
taskQueued: "排隊中",
needTaskInput: "等你提供必要資訊",
loginNeedsYou: "需要你登入",
running: "電腦已啟動",
privateComputerHint: "使用獨立環境,需要額外的電腦資源",
noMcp: "尚未連接服務。需要時再加入即可。",
chooseMcp: "連接服務",
connectMcpServer: "加入工作會用到的服務",
plugins: "連接服務",
authorizationNeedsYou: "需要你的授權",
verificationNeedsYou: "需要你完成驗證",
servicesHelp: "連接工作會用到的服務,讓助理能替你處理更多事情。",
memoryDiagnostics: "記憶診斷",
workInstructionsHint: "告訴它負責的工作、偏好與完成標準。",
workInstructions: "工作指示",
results: "成果",
advancedSettings: "進階設定",
sharedDesktop: "共同操作",
sharedNeedsUser: "{name} 已暫停等你完成畫面上的步驟。完成後按「完成,繼續」。",
doneContinue: "完成,繼續",
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤",
stopped: "已關閉", booting: "啟動中", suspended: "休眠中", error: "發生錯誤",
openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", takeOverNow: "接手操作", releaseControl: "釋放控制", done: "完成", skip: "略過",
localWorkspace: "本機工作區", pinned: "已釘選", agentGroup: "Agent",
operationFailed: "操作失敗", loadFailed: "載入失敗", settingsFailed: "設定失敗", loginFailed: "登入失敗",
rememberFailed: "無法記住這則訊息", clipboardWriteBlocked: "瀏覽器封鎖剪貼簿寫入。",
newConversation: "新對話", workTools: "工作工具", computer: "電腦", memory: "記憶", plugins: "外掛程式", pluginShort: "外掛", botSettings: "機器人設定",
newConversation: "新對話", workTools: "工作工具", computer: "電腦", memory: "記憶", pluginShort: "外掛", botSettings: "機器人設定",
add: "新增", addBot: "新增機器人", addGroup: "新增群組", groups: "群組",
unreadMessages: "{count} 則未讀訊息", members: "{count} 位成員", hideHiddenItems: "隱藏已隱藏項目", showHiddenItems: "顯示已隱藏項目",
mcpConnected: "{count} 個 MCP 已連線", connectMcpServer: "接入 MCP server",
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
mcpConnected: "{count} 個 MCP 已連線", openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 都在這裡。@誰就由誰回覆,沒點名時由主持人或最合適的 Agent 接手。",
everyoneMention: "所有人", mentionEveryoneHint: "每個人都回覆", mentionList: "可點名的成員",
openAgentChat: "開啟與 {name} 的對話",
@ -49,7 +74,7 @@ export const zhTW = {
exportSkill: "匯出", exportSkillHint: "下載成 JSON之後可以匯入到其他機器人",
importSkill: "匯入技能", importSkillHint: "從先前匯出的 JSON 檔加入技能",
skillImportInvalid: "這不是有效的技能檔。請選擇先前匯出的 JSON。",
collapseSidebar: "收合側欄", botComputer: "{name} 的電腦", dedicatedScreen: "獨立螢幕", enlarge: "放大",
collapseSidebar: "收合側欄", collapseNav: "收合對話列表", expandNav: "展開對話列表", botComputer: "{name} 的電腦", dedicatedScreen: "獨立螢幕", enlarge: "放大",
userControlling: "你正在控制", aiReadOnly: "AI 操作中(唯讀)", readOnly: "唯讀", pasteClipboard: "貼上剪貼簿", copyDesktopClipboard: "複製桌面剪貼簿", moreActions: "更多操作",
unpin: "取消釘選", pin: "釘選", markUnread: "標示為未讀", enterGroupName: "輸入分組名稱", changeGroup: "變更分組", createOrMoveGroup: "建立/移入分組", removeFromGroup: "移出分組", unhide: "取消隱藏", hide: "隱藏", delete: "刪除",
chooseConversation: "選擇對話", deleteConversation: "刪除對話", clearConversation: "清除此對話",
@ -116,15 +141,15 @@ export const zhTW = {
echoPlaceholder: "要回傳的文字", chooseBotForTools: "選一個機器人後,才能在它的 Computer 上安裝套件。",
computerToolsReady: "可用", computerToolsRevoked: "已撤銷", echoCallResult: "回傳",
command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP",
chooseMcp: "選擇 MCP", mcpPickerTitle: "選擇 MCP", mcpPickerLead: "從精選或官方市集挑一個,點下去接入。所有 Agent 都能用它的工具。",
mcpPickerTitle: "選擇 MCP", mcpPickerLead: "從精選或官方市集挑一個,點下去接入。所有 Agent 都能用它的工具。",
searchMcp: "搜尋 MCP", mcpRemote: "遠端", mcpLocal: "本機", mcpAdded: "已接入", mcpNeedsKey: "需要金鑰",
mcpNoResults: "找不到符合的 MCP。換個關鍵字或改用自訂接入。", mcpCustom: "自訂接入", mcpBackToList: "回到列表",
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartComputer: "重啟", shutDownComputer: "關閉電腦", computerPower: "電腦選單", sharedPowerHint: "此為共用電腦,操作會影響使用它的所有 Agent。", stopAndTakeOver: "停止並接管", takeoverBusy: "請先停止任務,再接手滑鼠。",
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", create: "建立",
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupHostHint: "第一位({name})先擔任主持人,以後在上方一點就能換。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
deleteGroup: "刪除群組", deleteNamed: "刪除 {name}", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。",
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP例如 http://192.168.x.x:3101。",
@ -147,7 +172,7 @@ export const zhTW = {
helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。",
meetingMode: "會議模式", exitMeetingMode: "結束會議模式",
helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。手機沒有這個模式。",
helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。對話列表可以收合,好讓聊天區大一點。手機沒有這個模式。",
helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入市集 MCP工作區連線。同一頁也可把審核過的套件裝到這台 Computer 上,行程在容器裡跑。",
helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。",
@ -183,7 +208,7 @@ export const zhTW = {
accountUsername: "帳號", accountPassword: "密碼", accountPasswordKeep: "留空表示不改密碼",
accountNotes: "備註", accountNotesPlaceholder: "選填,例如:公司信箱",
addAccount: "新增帳號", noAccounts: "還沒有已存帳號。排程若要自動登入,先在這裡加一筆。",
loginNeedsYou: "需要你在鍵盤上登入", loginOpenScreen: "打開它的畫面", loginWhy: "登入之後:{why}",
loginOpenScreen: "打開它的畫面", loginWhy: "登入之後:{why}",
scheduleChip: "已排程", scheduleRunChip: "排程執行",
schedules: "排程", schedule: "排程", schedCreate: "新增排程", schedEmpty: "還沒有排程。對話裡說「以後每天 9 點…」或按+。",
schedPaused: "已暫停", schedRunNow: "立刻跑", schedRunning: "執行中…", schedActive: "啟用",
@ -216,13 +241,20 @@ export const zhTW = {
schedCalendar: "日曆排程:{expr}",
teachInProgress: "示範進行中:先按「完成示範」或「取消」,再送訊息。",
aiTimeout: "AI 回應逾時150 秒)",
presenceWorking: "正在做事",
presenceWaiting: "等你回覆",
presenceStalled: "失去心跳,正在重新接手",
resumeMidTask: "做到一半,需要你決定",
resumeBudget: "輪次用盡,尚未確認完成",
resumeLoop: "卡在同一個動作,需要你給方向",
resumePlan: "我打算這樣做",
resumeProgress: "{turns}/{limit} 輪",
resumeContinue: "繼續",
resumeStart: "開始做",
resumeStop: "就到這裡",
resumeSent: "繼續,把它做完。", monitorTitle: "即時記錄",
resumeSent: "繼續,把它做完。",
resumePlanSent: "開始做。",
monitorTitle: "即時記錄",
monitorQueued: "排隊中",
monitorTurnOf: "第 {turn}/{limit} 輪",
monitorTurn: "第 {turn} 輪",
@ -291,4 +323,19 @@ export const zhTW = {
fileEvidenceUnavailable: "目前指派的電腦無法查詢此操作。",
fileEvidenceStale: "核對期間電腦已變更,請重新核對。",
fileEvidenceLoadFailed: "無法核對檔案結果,請稍後重試。",
previewFile: "預覽",
downloadFile: "下載",
openFile: "開啟 {name}",
computerFiles: "檔案",
computerFilesEmpty: "這個資料夾是空的",
computerFilesNeedBoot: "先啟動電腦才能看檔案",
computerFilesLoadFailed: "無法列出檔案",
filePreviewLoading: "正在開啟檔案…",
filePreviewFailed: "無法開啟這個檔案",
filePreviewTooLarge: "檔案超過 8 MB無法在網頁預覽",
filePreviewUnavailable: "電腦未在執行,無法開啟檔案",
filePreviewInvalid: "這個路徑不能開啟",
filePreviewBinary: "這個檔案不能在網頁上預覽,請下載後開啟。",
folderUp: "上一層",
folderLabel: "資料夾",
} as const;

View File

@ -31,7 +31,7 @@
border-radius:12px;background:var(--input);padding:.75em .9em;direction:ltr;unicode-bidi:isolate
}
.md-body pre code{border:0;background:transparent;padding:0;font-size:.84em;white-space:pre}
.md-pre-wrap{position:relative;margin:.65em 0}
.md-pre-wrap{position:relative;max-width:100%;min-width:0;overflow-x:auto;margin:.65em 0}
.md-copy{
position:absolute;top:.45em;right:.45em;display:grid;place-items:center;
width:28px;height:28px;border:1px solid var(--border);border-radius:8px;
@ -42,7 +42,7 @@
.md-body blockquote{
margin:.65em 0;border-left:3px solid var(--accent);padding-left:.85em;color:var(--muted)
}
.md-body table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse;font-size:.92em}
.md-body table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;font-size:.92em}
.md-body th,.md-body td{
border:1px solid var(--border);padding:.4em .6em;text-align:left;overflow-wrap:break-word
}
@ -50,7 +50,15 @@
.md-body hr{margin:.9em 0;border:0;border-top:1px solid var(--border)}
.md-body img{display:block;max-width:100%;height:auto;margin:.65em 0;border-radius:12px}
.md-body input[type="checkbox"]{margin-right:.4em;accent-color:var(--accent)}
.message-body.md{white-space:normal}
.message-body.md{white-space:normal;max-width:100%;min-width:0}
.md-file-link,.md-file-thumb{
display:inline;margin:0;padding:0;border:0;border-radius:0;background:none;
color:var(--accent);font:inherit;line-height:inherit;
text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);
text-underline-offset:.16em;cursor:pointer
}
.md-file-link:hover,.md-file-thumb:hover{color:#7ee0c8}
.md-file-thumb{display:inline-flex;align-items:center;gap:6px;margin:.35em 0;padding:4px 8px;border:1px solid var(--border);border-radius:10px;background:var(--inset);text-decoration:none}
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:var(--on-cream)}
.user .md-body a{color:#0f766e}
.user .md-copy{background:#ececea;color:#555}

View File

@ -9,11 +9,23 @@ import "./markdown.css";
const protocolPattern = /^([a-z][a-z\d+.-]*):/i;
const safeProtocols = new Set(["http", "https", "mailto", "tel"]);
const workspacePathPattern = /^[\p{L}\p{N}._ -]+(?:\/[\p{L}\p{N}._ -]+)*$/u;
export function isWorkspaceFileUrl(url: string): boolean {
const value = url.trim();
if (!value || value.startsWith("#") || value.startsWith("/") || value.includes("\\") || value.includes("\0")) return false;
if (protocolPattern.test(value)) return false;
const segments = value.split("/");
if (segments.some(segment => !segment || segment === "." || segment === "..")) return false;
return workspacePathPattern.test(value);
}
export function sanitizeMarkdownUrl(url: string): string | undefined {
const value = url.trim();
const protocol = value.match(protocolPattern)?.[1]?.toLowerCase();
if (protocol) return safeProtocols.has(protocol) ? value : undefined;
if (value.startsWith("#") && !value.toLowerCase().startsWith("#javascript")) return value;
if (isWorkspaceFileUrl(value)) return value;
return undefined;
}
@ -66,17 +78,30 @@ function CodeBlock(props: ComponentPropsWithoutRef<"pre">) {
);
}
const components: Components = {
a({node: _node, ...props}) {
return <a {...props} target="_blank" rel="noreferrer noopener"/>;
function linkComponents(onOpenFile?:(path:string)=>void): Components {
return {
a({node: _node, href, children, ...props}) {
if (href && isWorkspaceFileUrl(href)) {
if (!onOpenFile) return <span>{children}</span>;
return <button type="button" className="md-file-link" title={t("openFile",{name:href})} onClick={event=>{event.preventDefault();event.stopPropagation();onOpenFile(href);}}>{children}</button>;
}
return <a {...props} href={href} target="_blank" rel="noreferrer noopener">{children}</a>;
},
img({node: _node, ...props}) {
return <img {...props} alt={props.alt ?? ""} loading="lazy"/>;
img({node: _node, src, alt, ...props}) {
if (src && isWorkspaceFileUrl(src)) {
const label = alt?.trim() || src;
if (!onOpenFile) return <span className="md-file-thumb">{label}</span>;
return <button type="button" className="md-file-thumb" title={t("openFile",{name:src})} onClick={event=>{event.preventDefault();event.stopPropagation();onOpenFile(src);}}>{label}</button>;
}
return <img {...props} src={src} alt={alt ?? ""} loading="lazy"/>;
},
pre({node: _node, ...props}) {
return <CodeBlock {...props}/>;
},
};
}
const components: Components = linkComponents();
export function CopyMessageButton({text}:{text:string}) {
const {copied, markCopied} = useCopiedFlag();
@ -139,13 +164,13 @@ function linkifyMentions(node:ReactNode,agents:MentionAgent[],onMention?:(id:str
return node;
}
function mentionComponents(agents:MentionAgent[],onMention?:(id:string)=>void):Components {
function mentionComponents(agents:MentionAgent[],onMention?:(id:string)=>void,onOpenFile?:(path:string)=>void):Components {
const wrap=(tag:"p"|"li"|"td"|"th"|"h1"|"h2"|"h3"|"h4"|"h5"|"h6"|"blockquote")=>
function MentionTag({node:_node,children,...props}:{node?:unknown;children?:ReactNode}){
return createElement(tag,props,linkifyMentions(children,agents,onMention));
};
return {
...components,
...linkComponents(onOpenFile),
p:wrap("p"),
li:wrap("li"),
td:wrap("td"),
@ -164,14 +189,16 @@ export const ChatMarkdown = memo(function ChatMarkdown({
children,
agents=[],
onMention,
onOpenFile,
}:{
children:string;
agents?:MentionAgent[];
onMention?:(id:string)=>void;
onOpenFile?:(path:string)=>void;
}) {
const mdComponents=useMemo(
()=>agents.length?mentionComponents(agents,onMention):components,
[agents,onMention],
()=>agents.length||onOpenFile?mentionComponents(agents,onMention,onOpenFile):components,
[agents,onMention,onOpenFile],
);
return (
<div className="md-body">

View File

@ -384,7 +384,7 @@
.create-menu-wrap{position:relative;margin-left:auto}
.brand .create-menu-wrap .icon-button{margin-left:0}
.brand .create-menu-wrap .icon-button,.brand .nav-toggle{margin-left:0}
.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:var(--menu);box-shadow:0 18px 50px rgba(0,0,0,.5)}
@ -516,6 +516,26 @@
.app-shell.meeting-mode .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5);grid-area:stage}
.app-shell.meeting-mode .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);grid-area:stage;box-shadow:-20px 0 60px #000}
.app-shell.left-collapsed.right-open{grid-template-columns:minmax(0,1fr) clamp(320px,30vw,440px)}
.app-shell.left-collapsed.right-collapsed{grid-template-columns:minmax(0,1fr)}
.app-shell.meeting-mode.left-collapsed,
.app-shell.meeting-mode.left-collapsed.right-open,
.app-shell.meeting-mode.left-collapsed.right-collapsed{
grid-template-columns:minmax(0,1fr) minmax(380px,42vw);
grid-template-areas:"stage chat";
}
.app-shell.left-collapsed .sidebar:not(.open){display:none}
.app-shell.left-collapsed .sidebar.open{
display:flex;position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);
grid-area:1 / 1;box-shadow:20px 0 60px #000;
}
.app-shell.left-collapsed .mobile-menu{display:inline-flex}
.app-shell.left-collapsed .mobile-nav-backdrop{
display:block;position:fixed;inset:0;z-index:39;border:0;border-radius:0;
padding:0;background:#0008;backdrop-filter:blur(3px);
}
.app-shell.left-collapsed .mobile-nav-close{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px}

View File

@ -1,30 +1,93 @@
import type { SessionEvent } from './live';
export interface ReplyDraft { runId:string; botId:string; generation:string; text:string; messageId?:string }
export interface ReplyDraft { runId:string; botId:string; generation:string; text:string; messageId?:string; sep?:boolean; status?:string; quiet?:boolean }
export type ReplyDrafts = Record<string,ReplyDraft>;
/** Once tokens are on screen, a later body may only grow or append — never shrink. */
export function keepShown(shown:string,body:string):string {
const have=shown.trim();
const next=body.trim();
if(!have)return body;
if(!next)return shown;
if(have.includes(next))return shown;
if(next.includes(have))return body;
return `${have}\n\n${next}`;
}
/** Committed bubble text: keep anything the live draft already showed. */
export function shownReplyText(message:{id:string;role?:string;body:string},drafts:ReplyDrafts):string {
if(message.role==='user')return message.body;
const draft=Object.values(drafts).find(item=>item.messageId===message.id);
return draft?.text?keepShown(draft.text,message.body):message.body;
}
/** Live bubble only while the transcript does not yet have this message. */
export function visibleReplyDrafts(drafts:ReplyDrafts,messages:Array<{id:string}>):ReplyDraft[] {
return Object.values(drafts).filter(draft=>{
if(!draft.text)return false;
if(draft.messageId&&messages.some(message=>message.id===draft.messageId))return false;
return true;
});
}
// Durable SSE ids remove duplicate frames in live.ts. A generation prevents a
// late frame from a failed attempt from joining the replacement attempt.
// Text that already appeared is never blanked: a new generation appends.
export function applyReplyEvent(current:ReplyDrafts,event:SessionEvent):ReplyDrafts {
const {kind,payload}=event;
if(kind==='session.cleared')return {};
const runId=payload.runId;
if(typeof runId!=='string')return current;
if(kind==='reply.progress'&&typeof payload.text==='string'){
const prev=current[runId];
const botId=typeof payload.botId==='string'?payload.botId:(prev?.botId||'');
if(payload.statusOnly===true){
if(prev?.messageId)return current;
return {...current,[runId]:{...(prev||{runId,botId,generation:'',text:''}),status:payload.text}};
}
const text=keepShown(prev?.text||'',payload.text);
return {...current,[runId]:{runId,botId,generation:prev?.generation||'',text,messageId:prev?.messageId,sep:false}};
}
if(kind==='reply.started'&&typeof payload.generation==='string'&&typeof payload.botId==='string'){
return {...current,[runId]:{runId,botId:payload.botId,generation:payload.generation,text:''}};
const prev=current[runId];
if(prev?.text&&!prev.messageId){
return {...current,[runId]:{...prev,botId:payload.botId,generation:payload.generation,quiet:payload.quiet===true,sep:true}};
}
return {...current,[runId]:{runId,botId:payload.botId,generation:payload.generation,text:'',status:prev?.status,quiet:payload.quiet===true}};
}
const draft=current[runId];
if(!draft)return current;
if(kind==='message.created'&&payload.role==='assistant'&&typeof payload.id==='string'&&typeof payload.body==='string'){
return {...current,[runId]:{...draft,text:payload.body,messageId:payload.id}};
return {...current,[runId]:{...draft,text:keepShown(draft.text,payload.body),messageId:payload.id,sep:false}};
}
if(draft.messageId)return current;
if(kind==='reply.delta'&&payload.generation===draft.generation&&typeof payload.text==='string'){
return {...current,[runId]:{...draft,text:draft.text+payload.text}};
if(kind==='tool.started'&&draft.quiet)return {...current,[runId]:{...draft,status:undefined}};
if(kind==='tool.started'&&draft.text.trim()){
return {...current,[runId]:{...draft,status:undefined}};
}
if((kind==='reply.reset'&&payload.generation===draft.generation)||
['run.completed','run.failed','run.paused','run.cancelled'].includes(kind)){
if(kind==='tool.started'&&!draft.text.trim()&&typeof payload.step==='string'){
const step=payload.step.trim();
if(!step)return current;
const text=step.startsWith('正在')?step:`正在${step}`;
return {...current,[runId]:{...draft,text,sep:false,status:undefined}};
}
if(kind==='reply.delta'&&payload.generation===draft.generation&&typeof payload.text==='string'){
const text=draft.sep&&draft.text?`${draft.text.replace(/\n+$/,'')}\n\n${payload.text}`:draft.text+payload.text;
return {...current,[runId]:{...draft,text,sep:false,status:undefined}};
}
if(kind==='reply.reset'&&payload.generation===draft.generation){
return current;
}
if(['run.completed','run.failed','run.paused','run.cancelled'].includes(kind)){
// Keep tokens that already rendered. The transcript fold uses this draft
// so a shorter official body cannot yank them back.
if(draft.messageId||draft.text)return {...current,[runId]:{...draft,status:undefined}};
const next={...current};delete next[runId];return next;
}
return current;
}
/** True while tokens are still arriving. A draft that already has a message id is on screen. */
export function replyStillStreaming(drafts:ReplyDrafts):boolean {
return Object.values(drafts).some(draft=>!draft.messageId);
}

View File

@ -4,7 +4,7 @@
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{grid-template-columns:200px minmax(0,1fr) 320px}}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{grid-template-columns:200px minmax(0,1fr) 320px}.app-shell.left-collapsed.right-open{grid-template-columns:minmax(0,1fr) 340px}.app-shell.left-collapsed.right-collapsed{grid-template-columns:minmax(0,1fr)}.app-shell.meeting-mode.left-collapsed,.app-shell.meeting-mode.left-collapsed.right-open,.app-shell.meeting-mode.left-collapsed.right-collapsed{grid-template-columns:minmax(0,1fr) minmax(340px,42vw);grid-template-areas:"stage chat"}}
@media(max-width:1050px){
.panel-open-btn{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
@ -13,8 +13,10 @@
.app-shell.right-open .side-card-backdrop,.app-shell.stage-open:not(.meeting-mode) .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
.app-shell:not(.meeting-mode).stage-open .meeting-stage:not(.is-hidden){position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{
display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,340px);grid-template-areas:"stage chat";
.app-shell.left-collapsed.right-open,.app-shell.left-collapsed.right-collapsed{grid-template-columns:minmax(0,1fr)}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed,
.app-shell.meeting-mode.left-collapsed,.app-shell.meeting-mode.left-collapsed.right-open,.app-shell.meeting-mode.left-collapsed.right-collapsed{
display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,42vw);grid-template-areas:"stage chat";
}
.app-shell.meeting-mode .sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}
.app-shell.meeting-mode .sidebar.open{transform:none}
@ -23,7 +25,11 @@
@media(max-width:700px){
.meeting-toggle{display:none!important}
.meeting-stage{display:none!important}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{display:block;grid-template-areas:none;grid-template-columns:none}
.nav-toggle{display:none!important}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed,
.app-shell.meeting-mode.left-collapsed,.app-shell.meeting-mode.left-collapsed.right-open,.app-shell.meeting-mode.left-collapsed.right-collapsed{display:block;grid-template-areas:none;grid-template-columns:none}
.app-shell.left-collapsed .sidebar,.app-shell.left-collapsed .sidebar:not(.open){display:flex;visibility:hidden;transform:translateX(-105%)}
.app-shell.left-collapsed .sidebar.open{display:flex;visibility:visible;transform:none}
}
@media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}}
@media(prefers-reduced-motion:reduce){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}}

View File

@ -35,4 +35,4 @@
.sched-editor .dialog-actions{flex-wrap:wrap}
.login-chip,.sched-chip{width:100%;overflow-wrap:anywhere}
.message:has(>.sched-chip),.message:has(>.login-chip){flex-direction:column;gap:8px}
.message.spoken>.sched-chip,.message.spoken>.login-chip{grid-column:2}
.message.spoken>.sched-chip,.message.spoken>.login-chip{grid-column:2;max-width:100%;min-width:0}

View File

@ -1,4 +1,4 @@
:root{font-family:"Huninn","jf open 粉圓",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71;--hairline:#171719;--elevated:#121214;--menu:#18181b;--input:#0c0c0d;--dialog:#111113;--on-cream:#1a1a1a;--on-send:#1b1b1c;--online:#22c55e;--unread:#3b82f6;--surface-hover:rgba(255,255,255,.06);--focus:#4b4b50;--error-bg:#2a1717;--error-fg:#fca5a5;--error-border:#5a2a2a;--danger-fill:#8f2828;--danger-line:#a43b3b;--danger-soft:#ff8585;--warn-bg:#221c0e;--warn-fg:#fcd68a;--warn-border:#5a4a1f}
:root{font-family:"Huninn","jf open 粉圓",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71;--hairline:#171719;--elevated:#121214;--menu:#18181b;--input:#0c0c0d;--dialog:#111113;--on-cream:#1a1a1a;--on-send:#1b1b1c;--online:#22c55e;--presence-waiting:#f5b544;--presence-stalled:#6f6f76;--unread:#3b82f6;--surface-hover:rgba(255,255,255,.06);--focus:#4b4b50;--error-bg:#2a1717;--error-fg:#fca5a5;--error-border:#5a2a2a;--danger-fill:#8f2828;--danger-line:#a43b3b;--danger-soft:#ff8585;--warn-bg:#221c0e;--warn-fg:#fcd68a;--warn-border:#5a4a1f}
*{box-sizing:border-box}

View File

@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { t } from "./i18n";
import { RunProbe } from "./run-monitor";
import type { MessageFile } from "./types";
export interface TaskSnapshot {
runId:string; botId:string; status:string; aliveUntil:string|null; serverTime:string;
receivedAt?:number;
report?:{state:string;summary:string;completed:string[];remaining:string[];request?:string;verification?:string;attempts?:string[];artifacts?:MessageFile[]}|null;
}
export function taskState(task:TaskSnapshot, now=Date.now()) {
if(task.status==="queued")return "taskQueued";
if(task.status==="waiting_takeover")return "taskWaiting";
if(task.status==="waiting_input")return "needTaskInput";
if(task.status==="completed")return task.report?.state==="complete"?"taskCompleted":"taskEnded";
if(task.status==="failed")return "taskFailed";
if(task.status==="cancelled")return "taskStopped";
const elapsed=now-(task.receivedAt??now);
if(!task.aliveUntil||Date.parse(task.aliveUntil)<=Date.parse(task.serverTime)+elapsed)return "taskDisconnected";
return task.report?.state==="recovering"?"taskRecovering":"taskWorking";
}
export function TaskStatus({tasks,onTakeover}:{tasks:TaskSnapshot[];onTakeover:()=>void}) {
const [now,setNow]=useState(Date.now());
useEffect(()=>{
const deadlines=tasks.filter(task=>["leased","running"].includes(task.status)&&task.aliveUntil).map(task=>(task.receivedAt??Date.now())+Date.parse(task.aliveUntil!)-Date.parse(task.serverTime)).filter(time=>time>Date.now());
if(!deadlines.length)return;
const timer=window.setTimeout(()=>setNow(Date.now()),Math.max(1,Math.min(...deadlines)-Date.now()+10));
return ()=>window.clearTimeout(timer);
},[tasks,now]);
if(!tasks.length)return null;
return <div className="task-status-list">{tasks.map(task=>{
const state=taskState(task,Date.now());const report=task.report;
return <div className="task-status" key={task.runId}>
<div className="task-status-heading" role="status"><i className={`task-dot ${state}`} aria-hidden="true"/><strong>{t(state)}</strong><span className="task-summary">{report?.summary}</span><RunProbe runId={task.runId} align="end" label={t("taskDetails")}><span className="task-probe" tabIndex={0}>{t("taskDetails")}</span></RunProbe></div>
{task.status==="waiting_takeover"&&<button className="outline" onClick={onTakeover}>{t("loginOpenScreen")}</button>}
{task.status==="waiting_input"&&report?.request&&<p>{report.request}</p>}
{report&&Boolean(report.remaining?.length||report.completed?.length)&&<details><summary>{t("taskDetails")}</summary>{report.completed?.length>0&&<p>{t("taskCompleted")}{report.completed.join("")}</p>}{report.remaining?.length>0&&<p>{t("taskFailed")}{report.remaining.join("")}</p>}{report.attempts?.map((attempt,i)=><p key={i}>{attempt}</p>)}{report.verification&&<p>{report.verification}</p>}</details>}
</div>;
})}</div>;
}

View File

@ -3,10 +3,16 @@ export type ComputerState = "stopped" | "booting" | "running" | "suspended" | "e
export type BlobatarShape = "round"|"organic"|"boxy"|"capsule"|"nub"|"cloud"|"droplet"|"hexagon"|"sun"|"triangle";
/** Stored values include pre-blobatar aliases so existing bots keep rendering. */
export type AvatarShape = BlobatarShape|"blob"|"squircle"|"diamond"|"drop"|"organic-4"|"organic-5"|"organic-6"|"organic-7"|"organic-8"|"organic-9"|"organic-10"|"organic-11"|"cat"|"bunny"|"star"|"heart"|"egg"|"ghost"|"sprout"|"cactus"|"mushroom"|"paw";
export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean }
/**
* What the presence dot may claim. `working` is the only state backed by a
* live worker lease; `stalled` means the run says it is executing but nobody
* is renewing it, so it must not render as alive.
*/
export type BotPresence = "idle"|"working"|"waiting"|"stalled";
export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean; working?:boolean; presence?:BotPresence }
export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number }
export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null; replyBots?:RoomMember[] }
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
export interface MessageFile { botId?:string; verified?:boolean; kind:"image"|"file"; name:string; mimeType?:string; size?:number; path?:string }
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
export interface Room { id:string; name:string; members:RoomMember[]; hostBotId?:string|null; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; sharedInput?:boolean; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }

View File

@ -1,4 +1,40 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({plugins:[react(),{name:"desktop-viewer",generateBundle(){this.emitFile({type:"asset",fileName:"vnc.html",source:readFileSync(new URL("./vnc.html",import.meta.url),"utf8")})}}],build:{outDir:"dist",emptyOutDir:true},server:{port:5173,proxy:{"/api":{target:"http://127.0.0.1:3101",ws:true},"/view":{target:"http://127.0.0.1:3101",ws:true}}}});
function proxyToApi() {
return {
target: "http://127.0.0.1:3101",
ws: true,
configure(proxy: { on: (event: string, listener: (req: { getHeader: (name: string) => unknown; setHeader: (name: string, value: string) => void; removeHeader: (name: string) => void }) => void) => void }) {
proxy.on("proxyReq", (proxyReq) => {
const host = proxyReq.getHeader("host");
if (typeof host === "string" && host) proxyReq.setHeader("origin", `http://${host}`);
proxyReq.removeHeader("sec-fetch-site");
});
},
};
}
export default defineConfig({
plugins: [
react(),
{
name: "desktop-viewer",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "vnc.html",
source: readFileSync(new URL("./vnc.html", import.meta.url), "utf8"),
});
},
},
],
build: { outDir: "dist", emptyOutDir: true },
server: {
host: true,
port: 5173,
strictPort: true,
proxy: { "/api": proxyToApi(), "/view": proxyToApi() },
},
});

View File

@ -280,7 +280,7 @@
if (!value) mobileKey("BackSpace");
else {
const text = value.startsWith(sentinel) ? value.slice(1) : value;
if (text) pasteIntoDesktop(text);
if (text) window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
}
resetKeyboard();
}
@ -342,9 +342,65 @@
// Direct taps position the pointer; the keyboard opens only on request.
}, { passive: false, capture: true });
let lastPasteAt = 0;
let lastPasteText = "";
function sendCombo(modifiers, keysym, code) {
if (!rfb || rfb.viewOnly) return;
try {
for (const [ks, name] of modifiers) rfb.sendKey(ks, name, true);
rfb.sendKey(keysym, code, true);
rfb.sendKey(keysym, code, false);
for (let i = modifiers.length - 1; i >= 0; i--) rfb.sendKey(modifiers[i][0], modifiers[i][1], false);
} catch (_) {}
}
// Shift+Insert / Ctrl+Insert are the X11 CLIPBOARD keys: they paste and
// copy in GTK, Chromium, and xfce4-terminal without Ctrl+C sending SIGINT
// or needing Ctrl+Shift+V only in a terminal.
function sendPasteKey() { sendCombo([[0xffe1, "ShiftLeft"]], 0xff63, "Insert"); }
function sendCopyKey() { sendCombo([[0xffe3, "ControlLeft"]], 0xff63, "Insert"); }
function latin1(text) {
for (const ch of text) if ((ch.codePointAt(0) || 0) > 0xff) return false;
return true;
}
function typeText(text) {
if (!rfb || rfb.viewOnly) return;
try {
for (const ch of text) {
if (ch === "\n" || ch === "\r") {
rfb.sendKey(0xff0d, "Return", true);
rfb.sendKey(0xff0d, "Return", false);
continue;
}
const cp = ch.codePointAt(0) || 0;
const keysym = cp > 0xff ? (0x01000000 + cp) : cp;
rfb.sendKey(keysym, "Unidentified", true);
rfb.sendKey(keysym, "Unidentified", false);
}
} catch (_) {}
}
function pasteLocal(text) {
if (!rfb || rfb.viewOnly || !text) return;
if (latin1(text)) {
try { rfb.clipboardPasteFrom(text); } catch (_) {}
setTimeout(() => { if (rfb && !rfb.viewOnly) sendPasteKey(); }, 100);
return;
}
typeText(text);
}
function pasteIntoDesktop(text) {
if (!rfb || rfb.viewOnly || !text) return;
const now = Date.now();
if (text === lastPasteText && now - lastPasteAt < 400) return;
lastPasteText = text;
lastPasteAt = now;
// Multiline still goes through the confirmed backend (UTF-8 GTK
// clipboard, terminal-aware paste). Short clips paste locally so a
// stalled Cua round-trip cannot swallow Cmd/Ctrl+V.
if (text.includes("\n") || text.includes("\r") || text.length > 800) {
window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
return;
}
pasteLocal(text);
}
function pinTaskbar() {
@ -437,18 +493,25 @@
}
if (rfb && !rfb.viewOnly && event.metaKey && event.code === "KeyC") {
event.preventDefault(); event.stopImmediatePropagation();
sendCopyKey();
window.parent.postMessage({type:"lazyboy-copy-request"},parentOrigin); return;
}
if (!rfb || rfb.viewOnly || !(event.ctrlKey || event.metaKey) || event.altKey || event.code !== "KeyV") return;
event.preventDefault();
// Stop noVNC forwarding the shortcut (stale remote clipboard) but do
// not preventDefault: the paste event still carries clipboardData
// without clipboard-read permission, which iframe readText() often lacks.
event.stopImmediatePropagation();
const target = rfb;
try {
const text = await navigator.clipboard.readText();
if (rfb === target && !target.viewOnly) pasteIntoDesktop(text);
} catch (_) {
setTimeout(() => {
if (Date.now() - lastPasteAt > 250) {
window.parent.postMessage({ type: "lazyboy-paste-request" }, parentOrigin);
}
}, 50);
}
}, true);
connect();
window.addEventListener("resize", () => { pinTaskbar(); paintPointer(); });
@ -475,7 +538,7 @@
return;
}
if (event.data.type !== "lazyboy-host-clipboard") return;
pasteIntoDesktop(String(event.data.text || ""));
pasteLocal(String(event.data.text || ""));
});
</script>
</head>

View File

@ -185,33 +185,67 @@ impl BrowserRejection {
}
fn check_browser_request(headers: &HeaderMap) -> Result<(), BrowserRejection> {
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
return Err(BrowserRejection::CrossOrigin);
}
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return Err(BrowserRejection::InvalidHost);
};
if !served_at_an_allowed_host(host, &listed_hosts()) {
return Err(BrowserRejection::InvalidHost);
}
if let Some(origin) = headers.get(header::ORIGIN) {
let Some(origin) = origin
.to_str()
.ok()
.and_then(|s| reqwest::Url::parse(s).ok())
else {
return Err(BrowserRejection::CrossOrigin);
let origin_ok = match headers.get(header::ORIGIN) {
None => true,
Some(origin) => origin_matches_host(origin.to_str().unwrap_or(""), host),
};
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
let cross_site =
headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site");
if !origin_ok || (cross_site && headers.get(header::ORIGIN).is_none()) {
return Err(BrowserRejection::CrossOrigin);
};
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
return Err(BrowserRejection::CrossOrigin);
}
}
Ok(())
}
fn origin_matches_host(origin: &str, host: &str) -> bool {
let Ok(origin) = reqwest::Url::parse(origin) else {
return false;
};
if !matches!(origin.scheme(), "http" | "https") {
return false;
}
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
return false;
};
if origin.origin() == expected.origin() {
return true;
}
loopback_dev_origin(&origin, &expected)
}
fn is_loopback_host(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "::1") || host == "[::1]"
}
/// Local HMR (`:5173`) and `localhost` vs `127.0.0.1` are the same machine.
fn loopback_dev_origin(origin: &reqwest::Url, host: &reqwest::Url) -> bool {
if origin.scheme() != host.scheme() {
return false;
}
let Some(origin_host) = origin.host_str() else {
return false;
};
let Some(host_name) = host.host_str() else {
return false;
};
if !is_loopback_host(origin_host) || !is_loopback_host(host_name) {
return false;
}
let origin_port = origin.port_or_known_default();
let host_port = host.port_or_known_default();
origin_port == host_port
|| matches!(
(origin_port, host_port),
(Some(5173), Some(3101)) | (Some(3101), Some(5173))
)
}
#[cfg(test)]
fn allowed_browser_request(headers: &HeaderMap) -> bool {
check_browser_request(headers).is_ok()
@ -313,6 +347,28 @@ mod origin_tests {
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers));
headers.insert("sec-fetch-site", "cross-site".parse().unwrap());
assert!(
allowed_browser_request(&headers),
"same loopback origin is not a foreign site"
);
headers.remove("sec-fetch-site");
headers.remove(header::ORIGIN);
headers.insert("sec-fetch-site", "cross-site".parse().unwrap());
assert!(!allowed_browser_request(&headers));
}
#[test]
fn loopback_aliases_and_vite_hmr_are_the_same_machine() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, "127.0.0.1:3101".parse().unwrap());
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers));
headers.insert(header::ORIGIN, "http://127.0.0.1:5173".parse().unwrap());
assert!(allowed_browser_request(&headers));
headers.insert(header::ORIGIN, "http://localhost:5173".parse().unwrap());
headers.insert("sec-fetch-site", "cross-site".parse().unwrap());
assert!(allowed_browser_request(&headers));
headers.insert(header::ORIGIN, "http://localhost:9999".parse().unwrap());
assert!(!allowed_browser_request(&headers));
}

View File

@ -657,6 +657,28 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
boot_for(state, actor, bot_id, true).await
}
async fn wait_until_not_booting(state: &AppState, computer_id: &str) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
loop {
let computer = state
.db
.get_computer(computer_id)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?;
match computer.state.as_str() {
"booting" => {
if tokio::time::Instant::now() > deadline {
return Err("computer boot timed out".into());
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
"error" => return Err("computer boot failed".into()),
_ => return Ok(()),
}
}
}
/// `need_gui=false` provisions the Runner only: no `ensure_screen`, no viewer.
pub async fn boot_for(
state: &AppState,
@ -736,7 +758,8 @@ pub async fn boot_for(
// provision it. Allowing every caller that observes `booting` through
// here starts duplicate containers and can leave the row inconsistent.
if claimed.rows_affected() != 1 {
return Err("Computer is busy".into());
wait_until_not_booting(state, &computer_id).await?;
return Box::pin(boot_for(state, actor, bot_id, need_gui)).await;
}
let ctx = adapter_context(actor, bot_id, "boot");
let home = home_path(&state.data_dir, &computer.home_key);
@ -1976,7 +1999,9 @@ pub async fn current_status(
let waiting = active.iter().find(|run| run.status == "waiting_takeover");
let busy = active.iter().find(|run| {
parse_run_status(&run.status).is_some_and(|status| {
status.is_active() && status != lazyboy_contracts::RunStatus::WaitingTakeover
status.is_active()
&& status != lazyboy_contracts::RunStatus::WaitingTakeover
&& status != lazyboy_contracts::RunStatus::WaitingInput
})
});
// While the bot is paused for the human, later messages just queue up; the

View File

@ -1,9 +1,11 @@
//! Fit an in-run conversation into the model's context window.
//!
//! Computer-use runs have no turn quota, so every browser snapshot and shell
//! dump stays in `history` until the provider rejects the prompt. The latest
//! observation must stay readable (element ids live there). Older page dumps
//! are stubbed, long parts are capped, then the oldest turns are dropped.
//! dump would stay in `history` until the provider rejects the prompt. The
//! durable transcript is untouched: this module only shrinks the working set
//! sent to the model. The latest observation stays readable (element ids live
//! there). Older page dumps are stubbed, long parts are capped, then the
//! oldest turns are dropped.
use rig_core::completion::message::{
AssistantContent, Message, ReasoningContent, ToolResultContent, UserContent,
@ -16,14 +18,16 @@ const TRUNCATED_MARK: &str = "\n…(truncated)";
/// Conservative stand-in for one high-detail screenshot in the char budget.
const IMAGE_CHARS: usize = 8_000;
/// Latest history messages that stay "hot": capped, never stubbed.
const KEEP_RECENT: usize = 6;
/// Latest history messages that stay at the hot cap (pending is always hot).
const KEEP_RECENT: usize = 4;
/// Full page dumps kept in the working set, counting `pending` when it is one.
const KEEP_HOT_OBSERVATIONS: usize = 2;
/// Cap for a single text part on the latest turns / pending.
pub const HOT_PART: usize = 24 * 1024;
pub const HOT_PART: usize = 8 * 1024;
/// Cap for stubbed stale observations.
const STUB_PART: usize = 700;
/// Cap for other stale text (assistant thinking).
const COLD_PART: usize = 2 * 1024;
const COLD_PART: usize = 1024;
/// Default payload budget in bytes (preamble + tool schemas + messages).
/// ~2 bytes/token is conservative for mixed CJK; English is cheaper, so this
@ -123,7 +127,7 @@ pub fn fit_model_context(
let mut dropped = 0;
let mut compacted = false;
compacted |= stub_and_cap(history);
compacted |= stub_and_cap(history, pending);
compacted |= cap_message_parts_all(history, pending, HOT_PART, COLD_PART);
while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
@ -160,11 +164,11 @@ pub fn fit_model_context(
}
}
fn stub_and_cap(history: &mut [Message]) -> bool {
let hot_from = history.len().saturating_sub(KEEP_RECENT);
fn stub_and_cap(history: &mut [Message], pending: &Message) -> bool {
let keep = hot_observation_indices(history, pending);
let mut changed = false;
for (index, message) in history.iter_mut().enumerate() {
if index >= hot_from {
if keep.contains(&index) {
continue;
}
changed |= stub_observation_message(message);
@ -173,6 +177,41 @@ fn stub_and_cap(history: &mut [Message]) -> bool {
changed
}
fn hot_observation_indices(
history: &[Message],
pending: &Message,
) -> std::collections::HashSet<usize> {
let pending_obs = message_is_observation(pending);
let keep_in_history = KEEP_HOT_OBSERVATIONS.saturating_sub(usize::from(pending_obs));
let indices: Vec<usize> = history
.iter()
.enumerate()
.filter(|(_, message)| message_is_observation(message))
.map(|(index, _)| index)
.collect();
indices
.iter()
.rev()
.take(keep_in_history)
.copied()
.collect()
}
fn message_is_observation(message: &Message) -> bool {
match message {
Message::User { content } => content.iter().any(|part| {
match part {
UserContent::Text(text) => looks_like_observation(&text.text),
UserContent::ToolResult(result) => result.content.iter().any(|item| {
matches!(item, ToolResultContent::Text(text) if looks_like_observation(&text.text))
}),
_ => false,
}
}),
_ => false,
}
}
fn cap_message_parts_all(
history: &mut [Message],
pending: &mut Message,
@ -388,6 +427,43 @@ mod tests {
assert!(!text.text.contains(STUB_MARK));
}
#[test]
fn only_the_latest_two_observations_stay_full() {
let mut history = vec![
user(&observation("one", &"alpha ".repeat(80))),
assistant("click"),
user(&observation("two", &"bravo ".repeat(80))),
assistant("type"),
];
let mut pending = user(&observation("three", &"charlie ".repeat(80)));
stub_and_cap(&mut history, &pending);
cap_message_parts_all(&mut history, &mut pending, HOT_PART, COLD_PART);
let Message::User { content } = &history[0] else {
panic!("first")
};
let UserContent::Text(first) = &content[0] else {
panic!("first text")
};
assert!(first.text.contains(STUB_MARK));
assert!(!first.text.contains("alpha alpha"));
let Message::User { content } = &history[2] else {
panic!("second")
};
let UserContent::Text(second) = &content[0] else {
panic!("second text")
};
assert!(second.text.contains("bravo bravo"));
assert!(!second.text.contains(STUB_MARK));
let Message::User { content } = &pending else {
panic!("pending")
};
let UserContent::Text(third) = &content[0] else {
panic!("pending text")
};
assert!(third.text.contains("charlie charlie"));
assert!(!third.text.contains(STUB_MARK));
}
#[test]
fn a_72_turn_trace_fits_a_128k_class_budget() {
let dump = observation("page", &"x".repeat(4_000));

View File

@ -5,8 +5,8 @@ use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Utc};
use lazyboy_contracts::{
Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
computer_home_key, computer_scope_key,
Bot, BotPresence, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus,
SandboxKind, computer_home_key, computer_scope_key,
};
use serde_json::json;
use sqlx::{FromRow, PgPool};
@ -656,6 +656,8 @@ impl Db {
model_provider: model_provider.and_then(|value| value.parse().ok()),
model_id: model_id.map(str::to_string),
memory_enabled,
working: false,
presence: BotPresence::Idle,
})
}

View File

@ -31,11 +31,13 @@ mod skills;
mod state;
mod tool_install;
mod tools;
mod task_report;
mod vault;
mod voice;
mod voice_call;
mod web_static;
mod workspace;
mod workspace_files;
use std::net::SocketAddr;
use std::sync::Arc;

View File

@ -417,7 +417,7 @@ async fn room_status(
JOIN bots b ON b.id=r.bot_id
JOIN threads t ON t.id=r.thread_id
WHERE t.room_id=$1 AND t.space_id=$2 AND t.user_id=$3
AND r.status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND r.status IN ('queued','leased','running')
ORDER BY b.name",
)
.bind(&id)

View File

@ -1,9 +1,14 @@
use std::collections::HashMap;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::middleware;
use axum::routing::{any, delete, get, post};
use axum::{Json, Router};
use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput, UpdateBotInput};
use chrono::{DateTime, Utc};
use lazyboy_contracts::{
Bot, BotPresence, ComputerMode, CreateBotInput, RunStatus, UpdateBotInput, run_presence,
};
use serde::Deserialize;
use serde_json::{Value, json};
@ -25,6 +30,7 @@ pub fn router(state: AppState) -> Router {
.merge(crate::vault::router())
.merge(crate::schedules::router())
.merge(crate::artifacts::router())
.merge(crate::workspace_files::router())
.merge(crate::file_status::router())
.merge(crate::tool_install::router())
.route("/api/bots", get(list_bots).post(create_bot))
@ -71,9 +77,12 @@ async fn list_bots(
.list_bots(&actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let presence = bot_presence_map(&state, &actor).await;
Ok(Json(
rows.into_iter()
.map(|(bot, thread_id, computer)| Bot {
.map(|(bot, thread_id, computer)| {
let state = presence.get(&bot.id).copied().unwrap_or_default();
Bot {
id: bot.id,
space_id: bot.space_id,
name: bot.name,
@ -94,11 +103,52 @@ async fn list_bots(
model_provider: bot.model_provider.and_then(|value| value.parse().ok()),
model_id: bot.model_id,
memory_enabled: bot.memory_enabled,
working: state == BotPresence::Working,
presence: state,
}
})
.collect(),
))
}
/// Presence for every bot the actor owns, folded from that bot's active runs.
///
/// The lease is read alongside the status because a worker that died leaves its
/// run in `running` indefinitely; only `lease_expires_at` distinguishes work in
/// flight from an abandoned run.
async fn bot_presence_map(state: &AppState, actor: &Actor) -> HashMap<String, BotPresence> {
let rows: Vec<(String, String, Option<DateTime<Utc>>)> = sqlx::query_as(
"SELECT r.bot_id, r.status,
LEAST(r.lease_expires_at,
COALESCE((r.checkpoint->>'heartbeatAt')::timestamptz,r.updated_at)+interval '30 seconds')
FROM runs r
JOIN bots b ON b.id = r.bot_id
WHERE b.space_id = $1 AND b.user_id = $2
AND r.status IN ('queued','leased','running','waiting_input','waiting_takeover')",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.unwrap_or_default();
let now = Utc::now();
let mut map: HashMap<String, BotPresence> = HashMap::new();
for (bot_id, status, lease_expires_at) in rows {
let Some(status) = parse_run_status(&status) else {
continue;
};
let presence = run_presence(status, lease_expires_at, now);
map.entry(bot_id)
.and_modify(|current| *current = current.merge(presence))
.or_insert(presence);
}
map
}
fn parse_run_status(value: &str) -> Option<RunStatus> {
serde_json::from_value(Value::String(value.to_string())).ok()
}
async fn create_bot(
State(state): State<AppState>,
actor: Actor,
@ -148,6 +198,11 @@ async fn get_bot(
.ok_or(StatusCode::NOT_FOUND)?;
let screen = state.db.get_screen(&computer.id, &id).await.ok().flatten();
let status = computer::status_from(&id, &computer, screen.as_ref(), None);
let presence = bot_presence_map(&state, &actor)
.await
.get(&id)
.copied()
.unwrap_or_default();
Ok(Json(json!({
"bot": Bot {
id: bot.id,
@ -170,6 +225,8 @@ async fn get_bot(
model_provider: bot.model_provider.and_then(|value| value.parse().ok()),
model_id: bot.model_id,
memory_enabled: bot.memory_enabled,
working: presence == BotPresence::Working,
presence,
},
"computer": status,
})))
@ -198,7 +255,7 @@ async fn update_inbox(
"hide" => "UPDATE bots SET hidden=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"show" => "UPDATE bots SET hidden=FALSE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"group" => "UPDATE bots SET group_name=$4 WHERE id=$1 AND space_id=$2 AND user_id=$3",
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知"})))),
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知"})))),
};
let mut statement = sqlx::query(query)
.bind(&id)
@ -275,7 +332,7 @@ async fn update_bot(
if name.is_empty() || name.chars().count() > 80 || !color_ok || !shape_ok {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"設定格式不正確"})),
Json(json!({"message":"設定格弝丝正確"})),
));
}
let tags: Vec<String> = input
@ -287,7 +344,7 @@ async fn update_bot(
.collect();
let result = sqlx::query(
"UPDATE bots SET name=$1,title=$2,description=$3,avatar_color=$4,avatar_shape=$5,tags=$6,
memory_enabled=COALESCE($7,memory_enabled),updated_at=now()
memory_enabled=COALESCE($7,memory_enabled),instructions=COALESCE($11,instructions),updated_at=now()
WHERE id=$8 AND space_id=$9 AND user_id=$10",
)
.bind(name)
@ -300,6 +357,7 @@ async fn update_bot(
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(input.instructions.as_deref().map(str::trim))
.execute(state.pool())
.await
.map_err(internal_error)?;

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,22 @@ type ApiError = (StatusCode, Json<Value>);
/// lagged behind the channel, or a row written outside this process.
const EVENT_FALLBACK_POLL: Duration = Duration::from_secs(5);
async fn recv_live(
live: &mut tokio::sync::broadcast::Receiver<crate::state::LiveFrame>,
thread_id: &str,
) -> Option<crate::state::LiveFrame> {
loop {
match live.recv().await {
Ok(frame) if frame.thread_id == thread_id => return Some(frame),
Ok(_) => continue,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => return None,
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
std::future::pending::<()>().await
}
}
}
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
@ -39,6 +55,7 @@ pub fn router() -> Router<AppState> {
"/api/sessions/{id}/messages",
get(list_messages).post(send_message).delete(clear_messages),
)
.route("/api/sessions/{id}/task", get(task_snapshot))
.route("/api/sessions/{id}/events", get(events))
.route("/api/sessions/{id}/stop", post(stop_session))
}
@ -263,6 +280,23 @@ async fn delete_session(
Ok(StatusCode::NO_CONTENT)
}
async fn task_snapshot(
State(state): State<AppState>, actor: Actor, Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((StatusCode::NOT_FOUND,Json(json!({"message":"session not found"}))));
}
let rows: Vec<(String,String,String,Option<Value>,Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as(
"SELECT DISTINCT ON (bot_id) id,bot_id,status,checkpoint->'taskReport',
CASE WHEN checkpoint ? 'heartbeatAt' THEN LEAST(lease_expires_at, (checkpoint->>'heartbeatAt')::timestamptz + interval '30 seconds') ELSE NULL END
FROM runs WHERE thread_id=$1 ORDER BY bot_id,created_at DESC,id DESC")
.bind(&id).fetch_all(state.pool()).await.map_err(|e|internal(e.to_string()))?;
Ok(Json(json!(rows.into_iter().map(|(run_id,bot_id,status,report,expires)|json!({
"runId":run_id,"botId":bot_id,"status":status,"report":report,
"aliveUntil":expires,"serverTime":chrono::Utc::now(),
})).collect::<Vec<_>>())))
}
async fn list_messages(
State(state): State<AppState>,
actor: Actor,
@ -296,7 +330,7 @@ async fn clear_messages(
sqlx::query(
"UPDATE threads
SET next_message_seq=1, history_summary='', history_summary_seq=0,
history_compacted_at=NULL, updated_at=now()
history_compacted_at=NULL, plan_shown=FALSE, updated_at=now()
WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(&id)
@ -383,6 +417,7 @@ async fn events(
// afterwards would open a window in which a commit could knock on a channel
// this reader is not listening to yet.
let wakes = state.wakes.subscribe();
let live = state.wakes.subscribe_live();
let stream_state = (
state,
id,
@ -390,11 +425,23 @@ async fn events(
after,
Vec::<(i32, String, Value)>::new(),
wakes,
live,
Vec::<(String, Value)>::new(),
);
let output = stream::unfold(
stream_state,
|(state, id, actor, mut after, mut pending, mut wakes)| async move {
|(state, id, actor, mut after, mut pending, mut wakes, mut live, mut live_pending)| async move {
loop {
if let Some((kind, payload)) = live_pending.pop() {
let event = Event::default()
.event(kind)
.json_data(payload)
.unwrap_or_else(|_| Event::default().event("error").data("{}"));
return Some((
Ok(event),
(state, id, actor, after, pending, wakes, live, live_pending),
));
}
if let Some((seq, kind, payload)) = pending.pop() {
after = seq;
let event = Event::default()
@ -402,7 +449,10 @@ async fn events(
.event(kind)
.json_data(payload)
.unwrap_or_else(|_| Event::default().event("error").data("{}"));
return Some((Ok(event), (state, id, actor, after, pending, wakes)));
return Some((
Ok(event),
(state, id, actor, after, pending, wakes, live, live_pending),
));
}
match sqlx::query_as::<_, (i32, String, Value)>(
"SELECT e.seq,e.type,e.payload FROM events e
@ -426,11 +476,13 @@ async fn events(
rows.reverse();
pending = rows;
}
// Idle means "wait to be knocked", not "sleep then guess":
// the reader blocks on the wake channel and re-reads the
// cursor the moment a writer commits.
Ok(_) | Err(_) => {
tokio::select! {
frame = recv_live(&mut live, &id) => {
if let Some(frame) = frame {
live_pending.push((frame.kind, frame.payload));
}
}
_ = wakes.wait(&id) => {}
_ = tokio::time::sleep(EVENT_FALLBACK_POLL) => {}
}
@ -659,7 +711,23 @@ pub(crate) async fn cancel_session_runs(
#[cfg(test)]
mod tests {
use super::{is_default_session_title, normalized_title, title_from_first_message};
use super::*;
#[sqlx::test(migrations = "../../migrations")]
async fn task_snapshot_is_scoped_and_preserves_waiting_and_failure(pool:sqlx::PgPool) {
use crate::workspace_files::tests::{fixture_state,seed_bot,actor};
let state=fixture_state(pool.clone(),lazyboy_sandbox::FakeSandbox::new());seed_bot(&pool,"running").await;
sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id,title) VALUES ('t','s','u','b','test')").execute(&pool).await.unwrap();
sqlx::query("INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,prompt,status,checkpoint,lease_expires_at) VALUES ('r','s','u','b','t','test','running',jsonb_build_object('heartbeatAt',now()),now()+interval '10 minutes')").execute(&pool).await.unwrap();
let snapshot=task_snapshot(State(state.clone()),actor(),Path("t".into())).await.unwrap().0;
assert_eq!(snapshot[0]["status"],"running");assert!(snapshot[0]["aliveUntil"].is_string());
let other=Actor{user_id:"other".into(),space_id:"s".into()};
assert_eq!(task_snapshot(State(state.clone()),other,Path("t".into())).await.unwrap_err().0,StatusCode::NOT_FOUND);
for status in ["waiting_takeover","waiting_input","failed","completed","cancelled"] {
sqlx::query("UPDATE runs SET status=$1 WHERE id='r'").bind(status).execute(&pool).await.unwrap();
assert_eq!(task_snapshot(State(state.clone()),actor(),Path("t".into())).await.unwrap().0[0]["status"],status);
}
}
#[test]
fn session_titles_are_bounded_and_have_a_default() {

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use lazyboy_control::SandboxProvider;
use lazyboy_sandbox::{DockerSandbox, FakeSandbox};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
@ -61,6 +62,16 @@ impl Drop for CallLease {
/// fallback poll rather than dropping a message, because the `events` table is
/// still the source of truth for order and replay.
const WAKE_CAPACITY: usize = 512;
const LIVE_CAPACITY: usize = 4096;
/// One in-process frame for tokens and progress. Not durable: reconnects
/// replay `events`, not these.
#[derive(Debug, Clone)]
pub struct LiveFrame {
pub thread_id: String,
pub kind: String,
pub payload: Value,
}
/// Signals the session event stream that a thread's cursor moved. A wake only
/// says "read it now"; it is what turns the browser's event feed from a poll
@ -68,12 +79,14 @@ const WAKE_CAPACITY: usize = 512;
#[derive(Clone)]
pub struct WakeBus {
sender: tokio::sync::broadcast::Sender<String>,
live: tokio::sync::broadcast::Sender<LiveFrame>,
}
impl WakeBus {
fn with_capacity(capacity: usize) -> Self {
let (sender, _) = tokio::sync::broadcast::channel(capacity);
Self { sender }
let (live, _) = tokio::sync::broadcast::channel(LIVE_CAPACITY);
Self { sender, live }
}
/// Never blocks and never fails a request: nobody listening, or a reader too
@ -82,11 +95,25 @@ impl WakeBus {
let _ = self.sender.send(thread_id.to_string());
}
/// Push a lossy live frame (token deltas, tool steps). Does not knock the
/// durable reader: that path re-queries `events`, and deltas are not there.
pub fn live(&self, thread_id: &str, kind: &str, payload: Value) {
let _ = self.live.send(LiveFrame {
thread_id: thread_id.to_string(),
kind: kind.to_string(),
payload,
});
}
pub fn subscribe(&self) -> WakeSubscription {
WakeSubscription {
receiver: self.sender.subscribe(),
}
}
pub fn subscribe_live(&self) -> tokio::sync::broadcast::Receiver<LiveFrame> {
self.live.subscribe()
}
}
impl Default for WakeBus {
@ -261,4 +288,14 @@ mod wake_bus_tests {
.await
.expect_err("the scoped subscription is untouched");
}
#[tokio::test]
async fn live_frames_do_not_knock_the_durable_reader() {
let bus = WakeBus::with_capacity(4);
let mut durable = bus.subscribe();
bus.live("thread-1", "reply.delta", serde_json::json!({"text": "x"}));
tokio::time::timeout(Duration::from_millis(50), durable.wait("thread-1"))
.await
.expect_err("token deltas must not force a database re-read");
}
}

View File

@ -0,0 +1,282 @@
//! Explicit, durable task updates. Prose never changes a run's lifecycle.
use crate::tools::{ToolCtx, ToolOutcome};
use lazyboy_control::resolve_bot_workspace_path;
use rig_core::completion::ToolDefinition;
use serde::Deserialize;
use serde_json::{Value, json};
pub const INSTRUCTIONS: &str = "Task ownership: use report_task for meaningful milestones, changed approaches, essential input, and verified completion. Do not narrate individual clicks or routine tool calls. Report only what you actually tried and observed. Preserve the original requested outcomes, completed work and remaining work. Before ending a work task you MUST successfully call report_task with state complete (remaining must be empty, verification must explain current evidence, artifacts must include every deliverable file) or needs_input (only missing essential information or authorization, give the exact request). For login/2FA/puzzle use request_takeover with intervention login or verification and explain only the human step and what YOU will continue afterwards. A report is not permission to stop early. If an approach fails, inspect fresh state and try another supported method. Never delegate your remaining work back to the user. Report completion only after checking all requested outcomes; a successful click or generated plan is not verification. Do not emit completion markers in chat; the report provides the lifecycle state.";
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum State {
Progress,
Recovering,
NeedsInput,
Complete,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Report {
state: State,
summary: String,
completed: Vec<String>,
remaining: Vec<String>,
#[serde(default)]
verification: String,
#[serde(default)]
request: String,
#[serde(default)]
reason: String,
#[serde(default)]
attempts: Vec<String>,
#[serde(default)]
artifacts: Vec<String>,
}
pub fn definition() -> ToolDefinition {
ToolDefinition {
name: "report_task".into(),
description: INSTRUCTIONS.into(),
parameters: json!({
"type":"object","additionalProperties":false,"properties":{
"state":{"type":"string","enum":["progress","recovering","needs_input","complete"]},
"summary":{"type":"string","description":"A concise, user-facing milestone or result in the user's language."},
"completed":{"type":"array","items":{"type":"string"}},
"remaining":{"type":"array","items":{"type":"string"}},
"verification":{"type":"string","description":"Current evidence supporting every completed outcome; required for complete."},
"request":{"type":"string","description":"The one essential input only the user can provide."},
"reason":{"type":"string","enum":["information","authorization"]},
"attempts":{"type":"array","items":{"type":"string"},"description":"Methods actually tried and their results."},
"artifacts":{"type":"array","items":{"type":"string"},"description":"All deliverable workspace file paths, relative to this bot or shared/."}
},"required":["state","summary","completed","remaining"]
}),
}
}
/// Visible final copy is concise; detailed verification stays in the task panel.
pub fn terminal_report(value: &Value) -> Option<(lazyboy_harness::execution::GoalOutcome, String, Value)> {
use lazyboy_harness::execution::GoalOutcome;
let outcome = match value.get("state").and_then(Value::as_str) {
Some("complete") => GoalOutcome::Complete,
Some("needs_input") => GoalOutcome::NeedsInput,
_ => return None,
};
let mut body=value.get("summary")?.as_str()?.to_string();
if outcome==GoalOutcome::NeedsInput {
if let Some(request)=value.get("request").and_then(Value::as_str) {
body.push_str("\n\n"); body.push_str(request);
}
}
Some((outcome,body,value.get("artifacts").cloned().unwrap_or(json!([]))))
}
fn validate(value: &Value) -> Result<Report, String> {
let report: Report =
serde_json::from_value(value.clone()).map_err(|e| format!("Invalid report: {e}"))?;
if report.summary.trim().is_empty() || report.summary.chars().count() > 800 {
return Err("Provide a concise summary (1800 characters).".into());
}
if report.artifacts.len() > 30 {
return Err("At most 30 deliverables per report.".into());
}
if matches!(report.state, State::Complete)
&& (!report.remaining.is_empty()
|| report.completed.is_empty()
|| report.verification.trim().is_empty())
{
return Err("Completion requires completed outcomes, current verification and no remaining work. Continue the original task.".into());
}
if matches!(report.state, State::NeedsInput)
&& (report.request.trim().is_empty()
|| !matches!(report.reason.as_str(), "information" | "authorization")
|| report.remaining.is_empty())
{
return Err("Request only essential information or authorization; state exactly what is needed and what work remains. For verification use request_takeover.".into());
}
Ok(report)
}
fn failure(message: String) -> ToolOutcome {
ToolOutcome {
text: message,
image: None,
pause: false,
blocks: vec![],
error_code: Some("TASK_REPORT_INVALID".into()),
}
}
pub async fn report(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let report = match validate(args) {
Ok(v) => v,
Err(e) => return failure(e),
};
let mut artifacts = Vec::new();
for path in &report.artifacts {
let Some(computer) = ctx.computer.lock().unwrap().clone() else {
return failure(
"No bound computer for deliverables. Create and verify the files first.".into(),
);
};
let resolved = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, path) {
Ok(v) => v,
Err(e) => return failure(e.to_string()),
};
let entry = match ctx
.sandbox
.stat_file(&computer, &resolved, &ctx.adapter())
.await
{
Ok(v) => v,
Err(_) => {
return failure(format!(
"Deliverable cannot be accessed: {path}. Repair it before completion."
));
}
};
if entry.get("kind").and_then(Value::as_str) != Some("file") {
return failure(format!("Deliverable is not a regular file: {path}"));
}
if entry
.get("size")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX)
> 8 * 1024 * 1024
{
return failure(format!(
"Deliverable exceeds the preview limit: {path}. Split or compress it before delivery."
));
}
// A successful metadata lookup alone does not establish read permission.
let bytes = match ctx
.sandbox
.read_file(&computer, &resolved, &ctx.adapter())
.await
{
Ok(v) => v,
Err(_) => return failure(format!("Deliverable cannot be read: {path}")),
};
artifacts.push(json!({"kind":"file","path":path,"name":path.rsplit('/').next().unwrap_or(path),"size":bytes.len(),"botId":ctx.bot_id,"verified":true}));
}
let mut stored = args.clone();
stored["artifacts"] = json!(artifacts);
stored["attempts"] = json!(report.attempts);
let previous: Option<Value> =
sqlx::query_scalar("SELECT checkpoint->'taskReport' FROM runs WHERE id=$1")
.bind(&ctx.run_id)
.fetch_one(&ctx.pool)
.await
.ok()
.flatten();
let repeated = previous.as_ref() == Some(&stored);
let saved=sqlx::query("UPDATE runs SET checkpoint=COALESCE(checkpoint,'{}'::jsonb)||jsonb_build_object('taskReport',$2::jsonb),updated_at=now() WHERE id=$1 AND status='running'")
.bind(&ctx.run_id).bind(&stored).execute(&ctx.pool).await;
if !matches!(saved,Ok(ref result) if result.rows_affected()==1) {
return failure("Task update could not be saved; do not claim completion.".into());
}
let blocks = if !repeated && matches!(report.state, State::Progress | State::Recovering) {
vec![json!({"kind":"progress"})]
} else {
vec![]
};
ToolOutcome {
text: if blocks.is_empty() {
"Task report saved. Now give the user your concise result or exact request, without repeating earlier milestones.".into()
} else {
report.summary
},
image: None,
pause: false,
blocks,
error_code: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_copy_does_not_repeat_internal_verification() {
let value=json!({"state":"complete","summary":"Report ready","verification":"sha256 internal evidence","artifacts":[]});
assert_eq!(terminal_report(&value).unwrap().1,"Report ready");
assert!(terminal_report(&json!({"state":"progress","summary":"working"})).is_none());
}
#[test]
fn completion_requires_evidence_and_no_remaining_work() {
let mut v = json!({"state":"complete","summary":"Done","completed":["report"],"remaining":[],"verification":"Read back the report"});
assert!(validate(&v).is_ok());
v["remaining"] = json!(["publish"]);
assert!(validate(&v).is_err());
v["remaining"] = json!([]);
v["verification"] = json!("");
assert!(validate(&v).is_err());
}
#[test]
fn routine_failure_cannot_be_a_request_for_input() {
let mut v = json!({"state":"needs_input","summary":"Search failed","completed":[],"remaining":["research"],"request":"Search for me","reason":"tool_error"});
assert!(validate(&v).is_err());
v["reason"] = json!("information");
v["request"] = json!("Which date range?");
assert!(validate(&v).is_ok());
}
#[sqlx::test(migrations = "../../migrations")]
async fn deliverables_are_verified_scoped_and_durable(pool: sqlx::PgPool) {
use crate::workspace_files::tests::{actor, fixture_state, seed_bot};
let sandbox = lazyboy_sandbox::FakeSandbox::new();
sandbox.insert_file("home", "bots/b/攻略.md", b"# Verified report".to_vec());
let state = fixture_state(pool.clone(), sandbox);
seed_bot(&pool, "running").await;
sqlx::query(
"INSERT INTO threads(id,space_id,user_id,bot_id,title) VALUES ('t','s','u','b','test')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,prompt,status) VALUES ('r','s','u','b','t','research and deliver','running')").execute(&pool).await.unwrap();
let computer = lazyboy_control::ComputerRef {
id: "c".into(),
home_key: "home".into(),
provider_ref: "provider".into(),
kind: serde_json::from_value(json!("docker")).unwrap(),
fresh: false,
};
let mut ctx = ToolCtx::for_tool_manager(
&state,
&actor(),
"b",
computer,
lazyboy_contracts::ComputerMode::Team,
);
ctx.run_id = "r".into();
ctx.session_id = "t".into();
let mut args = json!({"state":"complete","summary":"Report ready","completed":["research","report"],"remaining":[],"verification":"Read back the contents","artifacts":["missing.md"]});
assert!(report(&ctx, &args).await.error_code.is_some());
args["artifacts"] = json!(["bots/another/private.md"]);
assert!(report(&ctx, &args).await.error_code.is_some());
args["artifacts"] = json!(["攻略.md"]);
assert!(report(&ctx, &args).await.error_code.is_none());
let stored: Value =
sqlx::query_scalar("SELECT checkpoint->'taskReport' FROM runs WHERE id='r'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored["state"], "complete");
assert_eq!(stored["artifacts"][0]["verified"], true);
assert_eq!(stored["artifacts"][0]["botId"], "b");
args["state"] = json!("progress");
args["remaining"] = json!(["review"]);
assert_eq!(report(&ctx, &args).await.blocks.len(), 1);
assert!(
report(&ctx, &args).await.blocks.is_empty(),
"same milestone must not spam chat"
);
sqlx::query("UPDATE runs SET status='cancelled' WHERE id='r'")
.execute(&pool)
.await
.unwrap();
assert!(
report(&ctx, &args).await.error_code.is_some(),
"stopped work cannot publish completion"
);
}
}

View File

@ -341,6 +341,7 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
parameters: json!({
"type":"object",
"properties":{
"intervention":{"type":"string","enum":["login","verification","authorization"]},
"reason":{"type":"string"},
"site":{"type":"string","description":"Site or app name, e.g. Gmail"},
"why":{"type":"string","description":"What you will do once they have signed in"}
@ -472,6 +473,7 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
},
]);
}
definitions.push(crate::task_report::definition());
definitions
}
@ -551,6 +553,7 @@ async fn dispatch_inner(
operation_id: &str,
) -> ToolOutcome {
match name {
"report_task" => crate::task_report::report(ctx,args).await,
"computer_observe" => observe(ctx).await,
"computer_act" => act(ctx, args).await,
"wait" => wait_then_observe(ctx, args).await,
@ -961,7 +964,7 @@ fn connection_takeover(ctx: &ToolCtx, reason: &str) -> ToolOutcome {
text: reason.into(),
image: None,
pause: true,
blocks: login_blocks(&json!({"reason":reason,"site":"網站連線驗證",
blocks: login_blocks(&json!({"intervention":"verification","reason":reason,"site":"網站連線驗證",
"why":"完成驗證後,繼續原本的瀏覽任務。"})),
error_code: None,
}
@ -2939,6 +2942,8 @@ fn login_blocks(args: &Value) -> Vec<Value> {
let why = if why.is_empty() { reason } else { why };
vec![json!({
"kind": "login",
"intervention": args.get("intervention").and_then(Value::as_str).filter(|v| matches!(*v,"login"|"verification"|"authorization")).unwrap_or("login"),
"reason": reason,
"site": site,
"why": why,
})]

View File

@ -0,0 +1,536 @@
//! Live workspace files on the bound Computer, for in-page preview.
//! Bytes stay on the Computer; the API only streams a bounded read.
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::routing::get;
use axum::{Json, Router};
use lazyboy_control::resolve_bot_workspace_path;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::db::{Actor, parse_mode};
use crate::state::AppState;
type ApiError = (StatusCode, Json<Value>);
const MAX_BYTES: u64 = 8 * 1024 * 1024;
const MAX_PATH: usize = 1024;
#[derive(Deserialize)]
struct WorkspaceQuery {
#[serde(default)]
path: String,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/bots/{id}/workspace", get(list_workspace))
.route("/api/bots/{id}/workspace/file", get(read_workspace_file))
}
async fn list_workspace(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
Query(query): Query<WorkspaceQuery>,
) -> Result<Json<Value>, ApiError> {
let bound = bind_workspace(&state, &actor, &bot_id, &query.path).await?;
let entries = tokio::time::timeout(std::time::Duration::from_secs(15), async {
state
.sandbox
.list_files(&bound.computer_ref, &bound.resolved, &bound.context)
.await
})
.await
.map_err(|_| json_error(StatusCode::GATEWAY_TIMEOUT, "workspace listing timed out"))?
.map_err(sandbox_error)?;
let prefix = if bound.resolved.is_empty() {
String::new()
} else {
format!("{}/", bound.resolved)
};
Ok(Json(json!({
"path": bound.resolved,
"entries": entries.into_iter().filter(|entry| {
bound.resolved.is_empty()
|| entry.path == bound.resolved
|| entry.path.starts_with(&prefix)
}).map(|entry| {
let name = entry.path.rsplit('/').next().unwrap_or(&entry.path);
json!({
"path": entry.path,
"name": name,
"kind": entry.kind,
"size": entry.size,
})
}).collect::<Vec<_>>()
})))
}
async fn read_workspace_file(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
Query(query): Query<WorkspaceQuery>,
) -> Result<(HeaderMap, Vec<u8>), ApiError> {
let bound = bind_workspace(&state, &actor, &bot_id, &query.path).await?;
if bound.resolved.is_empty() {
return Err(json_error(StatusCode::BAD_REQUEST, "file path is required"));
}
if let Ok(meta) = state
.sandbox
.stat_file(&bound.computer_ref, &bound.resolved, &bound.context)
.await
{
let kind = meta.get("kind").and_then(Value::as_str).unwrap_or("");
if kind == "dir" {
return Err(json_error(StatusCode::BAD_REQUEST, "path is a directory"));
}
if let Some(size) = meta.get("size").and_then(Value::as_u64)
&& size > MAX_BYTES
{
return Err(json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"file exceeds 8 MiB preview limit",
));
}
}
let bytes = tokio::time::timeout(std::time::Duration::from_secs(25), async {
state
.sandbox
.read_file(&bound.computer_ref, &bound.resolved, &bound.context)
.await
})
.await
.map_err(|_| json_error(StatusCode::GATEWAY_TIMEOUT, "workspace file timed out"))?
.map_err(sandbox_error)?;
if bytes.len() as u64 > MAX_BYTES {
return Err(json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"file exceeds 8 MiB preview limit",
));
}
if !computer_still_bound(&state, &actor, &bot_id, &bound).await? {
return Err(json_error(
StatusCode::CONFLICT,
"computer assignment changed during download",
));
}
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static(preview_content_type(&bound.resolved)),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
HeaderValue::from_str(&file_disposition(
&bound.resolved,
inline_preview(&bound.resolved),
))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
Ok((headers, bytes))
}
struct BoundWorkspace {
computer_id: String,
generation: i32,
provider_ref: String,
home_key: String,
scope: String,
computer_ref: lazyboy_control::ComputerRef,
context: lazyboy_control::AdapterContext,
resolved: String,
}
async fn bind_workspace(
state: &AppState,
actor: &Actor,
bot_id: &str,
requested: &str,
) -> Result<BoundWorkspace, ApiError> {
if requested.len() > MAX_PATH {
return Err(json_error(StatusCode::BAD_REQUEST, "path is too long"));
}
let bot = state
.db
.get_bot(actor, bot_id)
.await
.map_err(internal)?
.ok_or_else(|| json_error(StatusCode::NOT_FOUND, "bot not found"))?;
let computer_id = bot
.computer_id
.as_deref()
.ok_or_else(|| json_error(StatusCode::NOT_FOUND, "computer not found"))?;
let computer = state
.db
.get_computer(computer_id)
.await
.map_err(internal)?
.filter(|row| row.user_id == actor.user_id && row.space_id == actor.space_id)
.ok_or_else(|| json_error(StatusCode::NOT_FOUND, "computer not found"))?;
let resolved = resolve_bot_workspace_path(parse_mode(&computer.scope), bot_id, requested)
.map_err(|_| {
json_error(
StatusCode::BAD_REQUEST,
"path escapes the computer workspace",
)
})?;
let Some(computer_ref) =
crate::computer::computer_ref(&computer).filter(|_| computer.state == "running")
else {
return Err(json_error(StatusCode::CONFLICT, "computer is not running"));
};
let mut context = crate::computer::adapter_context(actor, bot_id, "workspace-preview");
context.computer_generation = Some(computer.generation);
Ok(BoundWorkspace {
computer_id: computer.id.clone(),
generation: computer.generation,
provider_ref: computer_ref.provider_ref.clone(),
home_key: computer.home_key.clone(),
scope: computer.scope.clone(),
computer_ref,
context,
resolved,
})
}
async fn computer_still_bound(
state: &AppState,
actor: &Actor,
bot_id: &str,
bound: &BoundWorkspace,
) -> Result<bool, ApiError> {
sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM computers c
JOIN bots b ON b.computer_id=c.id
WHERE b.id=$1 AND b.user_id=$2 AND b.space_id=$3
AND c.id=$4 AND c.user_id=b.user_id AND c.space_id=b.space_id
AND c.provider_ref=$5 AND c.generation=$6 AND c.state='running'
AND c.home_key=$7 AND c.scope=$8
)",
)
.bind(bot_id)
.bind(&actor.user_id)
.bind(&actor.space_id)
.bind(&bound.computer_id)
.bind(&bound.provider_ref)
.bind(bound.generation)
.bind(&bound.home_key)
.bind(&bound.scope)
.fetch_one(state.pool())
.await
.map_err(internal)
}
fn sandbox_error(error: lazyboy_control::SandboxError) -> ApiError {
let message = error.to_string();
let lower = message.to_lowercase();
if lower.contains("not found") || message.starts_with("FILE_NOT_FOUND") {
return json_error(StatusCode::NOT_FOUND, "file not found");
}
if lower.contains("size limit") || message.starts_with("SIZE_LIMIT") {
return json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"file exceeds 8 MiB preview limit",
);
}
if message.starts_with("PERMISSION_DENIED") {
return json_error(StatusCode::FORBIDDEN, "file is not readable");
}
json_error(StatusCode::BAD_GATEWAY, "workspace file could not be read")
}
fn json_error(status: StatusCode, message: &'static str) -> ApiError {
(status, Json(json!({"message": message})))
}
fn internal<E: std::fmt::Display>(error: E) -> ApiError {
tracing::error!("workspace files: {error}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
fn file_extension(path: &str) -> String {
path.rsplit('/')
.next()
.unwrap_or(path)
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.unwrap_or_default()
}
fn preview_content_type(path: &str) -> &'static str {
match file_extension(path).as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"md" | "markdown" => "text/markdown; charset=utf-8",
"txt" | "log" | "csv" | "html" | "htm" | "xml" | "yaml" | "yml" | "toml" | "rs" | "ts"
| "tsx" | "js" | "py" | "css" | "sh" => "text/plain; charset=utf-8",
"json" => "application/json; charset=utf-8",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
fn inline_preview(path: &str) -> bool {
matches!(
file_extension(path).as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg"
)
}
fn file_disposition(path: &str, inline: bool) -> String {
let name = path.rsplit('/').next().unwrap_or("file");
let encoded = name
.as_bytes()
.iter()
.map(|byte| {
if byte.is_ascii_alphanumeric() || b"-._".contains(byte) {
(*byte as char).to_string()
} else {
format!("%{byte:02X}")
}
})
.collect::<String>();
let kind = if inline { "inline" } else { "attachment" };
format!("{kind}; filename=\"download\"; filename*=UTF-8''{encoded}")
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use lazyboy_sandbox::FakeSandbox;
use std::sync::Arc;
pub(crate) fn fixture_state(pool: sqlx::PgPool, sandbox: FakeSandbox) -> AppState {
AppState {
db: crate::db::Db { pool },
sandbox: Arc::new(sandbox),
data_dir: String::new(),
auth: crate::auth::AuthConfig::from_env(),
memory: crate::memory::MemoryService::from_env(),
mcp: crate::mcp::McpHub::new(),
calls: crate::state::CallRegistry::default(),
wakes: crate::state::WakeBus::default(),
}
}
pub(crate) async fn seed_bot(pool: &sqlx::PgPool, state: &str) {
sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')")
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')")
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key,state,provider_ref,generation) VALUES ('c','s','u','team','team:s','home',$1,'provider',1)")
.bind(state)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES ('b','s','u','test','c')")
.execute(pool)
.await
.unwrap();
}
pub(crate) fn actor() -> Actor {
Actor {
user_id: "u".into(),
space_id: "s".into(),
}
}
#[test]
fn html_is_never_served_as_html() {
assert_eq!(
preview_content_type("page.html"),
"text/plain; charset=utf-8"
);
assert_eq!(
preview_content_type("index.htm"),
"text/plain; charset=utf-8"
);
assert!(!inline_preview("page.html"));
}
#[test]
fn images_are_inlined_and_markdown_is_text() {
assert_eq!(preview_content_type("chart.png"), "image/png");
assert!(inline_preview("chart.png"));
assert_eq!(
preview_content_type("notes.md"),
"text/markdown; charset=utf-8"
);
assert!(!inline_preview("notes.md"));
}
#[sqlx::test(migrations = "../../migrations")]
async fn preview_reads_workspace_file_and_rejects_escapes(pool: sqlx::PgPool) {
let sandbox = FakeSandbox::new();
sandbox.insert_file("home", "bots/b/notes.md", b"# Hello\n".to_vec());
sandbox.insert_file("home", "bots/b/chart.png", b"\x89PNG".to_vec());
sandbox.insert_file(
"home",
"bots/b/page.html",
b"<script>alert(1)</script>".to_vec(),
);
let state = fixture_state(pool.clone(), sandbox);
seed_bot(&pool, "running").await;
let (headers, bytes) = read_workspace_file(
State(state.clone()),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: "notes.md".into(),
}),
)
.await
.unwrap();
assert_eq!(bytes, b"# Hello\n");
assert_eq!(headers["content-type"], "text/markdown; charset=utf-8");
assert_eq!(headers["x-content-type-options"], "nosniff");
assert!(
headers["content-disposition"]
.to_str()
.unwrap()
.starts_with("attachment;")
);
let (headers, bytes) = read_workspace_file(
State(state.clone()),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: "page.html".into(),
}),
)
.await
.unwrap();
assert_eq!(bytes, b"<script>alert(1)</script>");
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
let listed = list_workspace(
State(state.clone()),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: String::new(),
}),
)
.await
.unwrap();
assert_eq!(listed.0["path"], "bots/b");
assert!(
listed.0["entries"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["name"] == "notes.md")
);
assert_eq!(
read_workspace_file(
State(state.clone()),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: "../etc/passwd".into(),
}),
)
.await
.unwrap_err()
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
read_workspace_file(
State(state.clone()),
Actor {
user_id: "other".into(),
space_id: "s".into(),
},
Path("b".into()),
Query(WorkspaceQuery {
path: "notes.md".into(),
}),
)
.await
.unwrap_err()
.0,
StatusCode::NOT_FOUND
);
assert_eq!(
read_workspace_file(
State(state),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: "missing.md".into(),
}),
)
.await
.unwrap_err()
.0,
StatusCode::NOT_FOUND
);
}
#[sqlx::test(migrations = "../../migrations")]
async fn preview_requires_running_computer_and_size_limit(pool: sqlx::PgPool) {
let sandbox = FakeSandbox::new();
sandbox.insert_file("home", "bots/b/huge.bin", vec![0; (MAX_BYTES as usize) + 1]);
let state = fixture_state(pool.clone(), sandbox);
seed_bot(&pool, "stopped").await;
assert_eq!(
read_workspace_file(
State(state.clone()),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: "huge.bin".into(),
}),
)
.await
.unwrap_err()
.0,
StatusCode::CONFLICT
);
sqlx::query("UPDATE computers SET state='running' WHERE id='c'")
.execute(&pool)
.await
.unwrap();
assert_eq!(
read_workspace_file(
State(state),
actor(),
Path("b".into()),
Query(WorkspaceQuery {
path: "huge.bin".into(),
}),
)
.await
.unwrap_err()
.0,
StatusCode::PAYLOAD_TOO_LARGE
);
}
}

View File

@ -1,7 +1,79 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{ComputerMode, ModelProvider};
use crate::{ComputerMode, ModelProvider, RunStatus};
/// What the presence dot is allowed to claim about a bot.
///
/// The only honest source for "still alive" is the worker lease: `execute_run`
/// renews its heartbeat every ten seconds; the API bounds the lease by that heartbeat, so a fresh lease proves some worker is still
/// holding the run. Run status alone cannot prove liveness, because a worker
/// that died leaves its run sitting in `running` forever.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BotPresence {
/// No active run. The bot is still reachable: a new message wakes it.
#[default]
Idle,
/// A turn is executing with a lease and recent worker heartbeat.
Working,
/// Paused until the human answers or takes over the computer.
Waiting,
/// The run claims to be executing but its lease went stale, so whoever was
/// running it is gone. Must never render as alive.
Stalled,
}
impl BotPresence {
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Working => "working",
Self::Waiting => "waiting",
Self::Stalled => "stalled",
}
}
/// Ranking used to fold several active runs into one dot. Anything that is
/// genuinely moving outranks the rest, so `Stalled` only surfaces when
/// nothing else is making progress, which is exactly when it matters.
fn rank(self) -> u8 {
match self {
Self::Working => 3,
Self::Waiting => 2,
Self::Stalled => 1,
Self::Idle => 0,
}
}
pub fn merge(self, other: Self) -> Self {
if other.rank() > self.rank() {
other
} else {
self
}
}
}
/// Presence for a single run.
///
/// `Queued` carries no lease yet because no worker has claimed it, so a missing
/// lease there is normal rather than stale.
pub fn run_presence(
status: RunStatus,
lease_expires_at: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> BotPresence {
match status {
RunStatus::Queued => BotPresence::Idle,
RunStatus::Leased | RunStatus::Running => match lease_expires_at {
Some(expiry) if expiry > now => BotPresence::Working,
_ => BotPresence::Stalled,
},
RunStatus::WaitingInput | RunStatus::WaitingTakeover => BotPresence::Waiting,
RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled => BotPresence::Idle,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -52,11 +124,18 @@ pub struct Bot {
pub model_provider: Option<ModelProvider>,
pub model_id: Option<String>,
pub memory_enabled: bool,
/// Kept as a convenience mirror of `presence == Working` for callers that
/// only need the boolean.
#[serde(default)]
pub working: bool,
#[serde(default)]
pub presence: BotPresence,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateBotInput {
pub instructions: Option<String>,
pub name: String,
#[serde(default)]
pub title: String,
@ -68,3 +147,86 @@ pub struct UpdateBotInput {
pub tags: Vec<String>,
pub memory_enabled: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn now() -> DateTime<Utc> {
Utc::now()
}
#[test]
fn a_renewed_lease_counts_as_working() {
let at = now();
assert_eq!(
run_presence(RunStatus::Running, Some(at + Duration::minutes(4)), at),
BotPresence::Working
);
}
#[test]
fn an_expired_lease_is_stalled_not_working() {
let at = now();
for status in [RunStatus::Leased, RunStatus::Running] {
assert_eq!(
run_presence(status, Some(at - Duration::seconds(1)), at),
BotPresence::Stalled
);
assert_eq!(run_presence(status, None, at), BotPresence::Stalled);
}
}
#[test]
fn queued_work_does_not_claim_a_live_worker() {
let at = now();
assert_eq!(
run_presence(RunStatus::Queued, None, at),
BotPresence::Idle
);
}
#[test]
fn paused_runs_report_waiting() {
let at = now();
for status in [RunStatus::WaitingInput, RunStatus::WaitingTakeover] {
assert_eq!(run_presence(status, None, at), BotPresence::Waiting);
}
}
#[test]
fn finished_runs_leave_the_bot_idle() {
let at = now();
for status in [
RunStatus::Completed,
RunStatus::Failed,
RunStatus::Cancelled,
] {
assert_eq!(
run_presence(status, Some(at + Duration::minutes(4)), at),
BotPresence::Idle
);
}
}
#[test]
fn merging_prefers_real_progress_over_a_stale_run() {
assert_eq!(
BotPresence::Stalled.merge(BotPresence::Working),
BotPresence::Working
);
assert_eq!(
BotPresence::Working.merge(BotPresence::Stalled),
BotPresence::Working
);
assert_eq!(
BotPresence::Stalled.merge(BotPresence::Waiting),
BotPresence::Waiting
);
assert_eq!(
BotPresence::Idle.merge(BotPresence::Stalled),
BotPresence::Stalled
);
}
}

View File

@ -30,18 +30,113 @@ impl ExecutionMode {
pub fn goal_outcome(reply: &str) -> GoalOutcome {
let reply = reply.trim();
// A bare marker provides neither verification nor a reason to the human.
if reply.lines().count() < 2 {
return GoalOutcome::Continue;
}
if reply.lines().count() < 2 { return GoalOutcome::Continue; }
match reply.lines().last().map(str::trim) {
Some("[GOAL_COMPLETE]") => GoalOutcome::Complete,
Some("[GOAL_BLOCKED]") => GoalOutcome::NeedsInput,
Some("[GOAL_BLOCKED]") | Some("[NEEDS_INPUT]") => GoalOutcome::NeedsInput,
_ => GoalOutcome::Continue,
}
}
/// Last paragraph already told the human the work is finished. Do not send
/// another "keep going" turn — that is what produces "it's done" then "it isn't".
pub fn reports_completion(reply: &str) -> bool {
let paragraphs: Vec<&str> = reply
.trim()
.split("\n\n")
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
let last = *paragraphs.last().unwrap_or(&"");
let mut text = last
.lines()
.map(str::trim)
.filter(|line| {
!line.is_empty()
&& !matches!(
*line,
"[GOAL_COMPLETE]" | "[GOAL_BLOCKED]" | "[NEEDS_INPUT]"
)
})
.collect::<Vec<_>>()
.join("\n");
if text.chars().count() < 8 && paragraphs.len() >= 2 {
text = format!("{}\n{text}", paragraphs[paragraphs.len() - 2]);
}
if text.chars().count() < 8 || text.contains('?') || text.contains('') {
return false;
}
let lower = text.to_lowercase();
const UNFINISHED: &[&str] = &[
"還沒",
"还没",
"尚未",
"未完成",
"沒好",
"没好",
"下一步",
"接下来",
"接下來",
"not done",
"not finished",
"not yet",
"still need",
"still missing",
"i will now",
"next i will",
];
const IN_PROGRESS: &[&str] = &[
"正在",
"我先",
"先打開",
"先打开",
"先搜",
"looking at",
"i am now",
"i'm now",
"i will now",
"next i will",
];
if UNFINISHED.iter().any(|marker| lower.contains(marker))
|| IN_PROGRESS.iter().any(|marker| lower.contains(marker))
{
return false;
}
const DONE: &[&str] = &[
"完成了",
"已完成",
"做好了",
"做完了",
"搞定了",
"處理好了",
"处理好了",
"已寫好",
"已写好",
"已寫入",
"已写入",
"已存好",
"存好了",
"已經存",
"已经存",
"saved to",
"i've saved",
"i saved",
"wrote to",
"it's done",
"all done",
"task is complete",
"finished.",
"finished!",
];
DONE.iter().any(|marker| lower.contains(marker))
}
pub const GOAL_INSTRUCTIONS: &str = "Persistent goal execution: plan the requested work, execute it, and verify each requested outcome. Intermediate progress replies do not finish the run. Preserve completed work and incorporate user steering. End your final reply with a standalone [GOAL_COMPLETE] line only when all outcomes are verified; explain the verification. When required information or human action is missing, explain exactly what is needed and end with a standalone [GOAL_BLOCKED] line. For a simple Cloudflare connection-check checkbox, observe the current screen and try connection_check once, then verify the requested content. For other CAPTCHA, failed verification, login or 2FA use request_takeover. Never claim completion merely because you planned the work.";
pub const GOAL_CONTINUE: &str = "The goal remains active. Continue the plan with tools and verify the outcome. Finish only with a standalone [GOAL_COMPLETE] line after verification, or [GOAL_BLOCKED] when required human input is missing.";
pub const GOAL_CONTINUE: &str = "The original goal remains active. Check every requested outcome against the current evidence. A completed substep or a failed approach does not finish the goal. If the whole goal is already verified, report it once with [GOAL_COMPLETE] without repeating work. Otherwise diagnose failures and use a different supported approach with tools. Report briefly what failed, what you actually tried and learned, and what you are trying next, then continue in the same turn. Do not ask the human how to debug, whether to continue, or to do work you can perform. Use [GOAL_BLOCKED] only for a concrete human-only requirement: login/2FA/puzzle CAPTCHA, missing essential information or authorization. State the exact requirement, preserve finished work, and resume it when supplied.";
pub const AUTONOMOUS_RECOVERY: &str = "You own delivery of the full goal, including debugging. Before acting, check obvious prerequisites cheaply; immediately request human help for a confirmed human-only blocker. For ordinary failures inspect fresh state, read the error and relevant evidence, identify the likely cause, and try a materially different supported method. Do not repeat an uncertain mutation or make the human choose debugging steps. Routine implementation choices are yours. Report only meaningful findings: the obstacle, methods actually tried and their results, and the next action; then call tools in the same turn. Never invent attempts. Completing a substep does not complete the goal. If all supported approaches are exhausted, give the evidence, partial deliverables and a concrete limitation, not generic advice to try it yourself. Do not mark such a task complete.";
pub const PLAN_INSTRUCTIONS: &str = "This turn is planning only — you have no tools and must not start the computer. In the user's language, write a short plan: the goal, 26 numbered steps, and where the result will go. Ask them to confirm before doing any work. Do not claim the work is already done.";
/// A run that stops in the middle must say so instead of going quiet. The
/// model marks the moment; the loop turns the marker into a paused run the
@ -69,6 +164,8 @@ pub enum StopReason {
/// stuck", not "it ran out of a quota", and the pause message carries the
/// action it kept repeating.
LoopDetected,
/// Multi-step work: show the plan and wait for the human to start it.
NeedsPlan,
}
impl StopReason {
@ -77,22 +174,21 @@ impl StopReason {
Self::MidTaskText => "mid_task_text",
Self::BudgetExhausted => "budget_exhausted",
Self::LoopDetected => "loop_detected",
Self::NeedsPlan => "needs_plan",
}
}
}
/// Only a standalone marker line asks for input, never a quoted mention.
pub fn asks_for_input(reply: &str) -> bool {
reply
.trim()
.lines()
.last()
.is_some_and(|line| line.trim() == NEEDS_INPUT_MARKER)
let last = reply.trim().lines().last().map(str::trim).unwrap_or("");
last == NEEDS_INPUT_MARKER || last == "[GOAL_BLOCKED]"
}
/// A run that never touched a tool answered in prose, which is a complete
/// reply. Once a run has done work, ending is a decision the human gets to
/// make: report where the work stands, then ask before stopping.
/// reply. Once a run has done work, a real blocker (`[NEEDS_INPUT]`) still
/// pauses for the human; a stall or spent budget reports where it stopped
/// and completes, instead of asking "continue?".
pub fn stop_reason(
mode: ExecutionMode,
turns: u32,
@ -100,6 +196,11 @@ pub fn stop_reason(
did_work: bool,
stalled: bool,
) -> Option<StopReason> {
if mode == ExecutionMode::Goal && stalled {
// A model that never called a tool has not delivered an action goal.
// Exhausted recovery must not fall through to a successful run.
return Some(StopReason::MidTaskText);
}
if !did_work {
return None;
}
@ -113,6 +214,13 @@ pub fn stop_reason(
mod tests {
use super::*;
#[test]
fn a_progress_sentence_is_not_completion() {
assert!(!reports_completion("正在打開搜尋頁。"));
assert!(!reports_completion("我先看一下畫面。"));
assert!(reports_completion("摘要已寫進 notes.md。做好了。"));
}
#[test]
fn a_prose_answer_is_not_a_stop() {
assert_eq!(
@ -121,6 +229,11 @@ mod tests {
);
}
#[test]
fn a_goal_that_never_starts_work_cannot_finish_as_success() {
assert_eq!(stop_reason(ExecutionMode::Goal, 9, "我會想辦法", false, true), Some(StopReason::MidTaskText));
}
#[test]
fn only_a_standalone_marker_asks_for_input() {
assert!(asks_for_input("我做到一半。\n[NEEDS_INPUT]"));
@ -205,5 +318,61 @@ mod tests {
goal_outcome("Please supply the date.\n[GOAL_BLOCKED]"),
GoalOutcome::NeedsInput
);
assert_eq!(
goal_outcome("已打開登入頁,但我沒有這個網站的帳號。\n需要你登入才能繼續。"),
GoalOutcome::Continue
);
assert_eq!(
goal_outcome("I hit a CAPTCHA I cannot solve.\nUnable to proceed without you."),
GoalOutcome::Continue
);
assert_eq!(
goal_outcome("Next is still disabled; I cannot click it yet so I will wait."),
GoalOutcome::Continue
);
}
#[test]
fn mentioning_a_blocker_does_not_request_input() {
assert!(!asks_for_input(
"登入需要驗證碼,我做不到這一步。\n需要你接手。"
));
assert!(!asks_for_input("我先點 Next 再看結果"));
}
#[test]
fn a_substep_summary_requires_whole_goal_verification() {
assert_eq!(
goal_outcome("已把摘要寫進 notes.md。\n做好了。"),
GoalOutcome::Continue
);
assert_eq!(
goal_outcome("Saved the report to shared/report.md. It's done."),
GoalOutcome::Continue
);
assert_eq!(
goal_outcome("頁面開好了,接下來點 Next。"),
GoalOutcome::Continue
);
assert_eq!(
goal_outcome("檔案還沒存好,我再寫一次。"),
GoalOutcome::Continue
);
assert!(!reports_completion("好了嗎"));
assert!(reports_completion("結果在 inbox/out.md已經存好。"));
assert_eq!(
goal_outcome("摘要在 notes.md。\n\n做好了。"),
GoalOutcome::Continue
);
}
#[test]
fn an_ordinary_failure_is_a_recovery_step_not_a_human_blocker() {
for reply in ["這個方法做不到,我會改用另一個工具。", "目前無法完成,先查看錯誤記錄。", "I cannot complete this using the current command."] {
assert_eq!(goal_outcome(reply), GoalOutcome::Continue);
assert!(!asks_for_input(reply));
}
assert_eq!(goal_outcome("缺少你要處理的日期,請提供日期。\n[GOAL_BLOCKED]"), GoalOutcome::NeedsInput);
assert_eq!(goal_outcome("已核對全部成果與輸出檔案。\n[GOAL_COMPLETE]"), GoalOutcome::Complete);
}
}

View File

@ -12,11 +12,12 @@
//! continues. Triggers are the same action again, the same failure again, no
//! new success for a long time, or a soft checkpoint.
//! 2. Derailment halt (`LoopDetected`): the same action keeps being repeated, or
//! nothing has worked for a very long time. The run parks in `waiting_input`
//! with the concrete evidence so the human can unblock it.
//! nothing has worked for a very long time. The run reports the stall and
//! completes; the human can send a new instruction if they want it to try
//! again. Real blockers (`[NEEDS_INPUT]`, login, captcha) still pause.
//! 3. Circuit breaker (`BudgetExhausted`): a large turn and wall-clock ceiling
//! that exists only so a bug cannot burn an API key overnight. It is not a
//! task budget; reaching it is a bug report, not a result.
//! task budget; reaching it is a spoken stall, not a "continue?" chip.
use std::collections::HashMap;
use std::time::Duration;
@ -39,20 +40,20 @@ pub const SOFT_WALL_MINUTES: u64 = 75;
pub const HARD_WALL_MINUTES: u64 = 240;
/// Same mutating action, in a row, before the run is coached.
pub const REPEAT_WARN: u32 = 3;
pub const REPEAT_WARN: u32 = 2;
/// Same mutating action, in a row, before the run parks.
pub const REPEAT_HALT: u32 = 6;
pub const REPEAT_HALT: u32 = 3;
/// Same mutating action, counted over the whole run.
pub const REPEAT_HALT_TOTAL: u32 = 12;
/// Same action failing, in a row.
pub const FAILURE_WARN: u32 = 3;
pub const FAILURE_HALT: u32 = 8;
pub const FAILURE_WARN: u32 = 2;
pub const FAILURE_HALT: u32 = 3;
/// Anything failing, in a row, whatever the tool.
pub const FAILURE_HALT_ANY: u32 = 14;
pub const FAILURE_HALT_ANY: u32 = 6;
/// Turns without a new success before a self-check.
pub const STALE_REFLECT: u32 = 40;
pub const STALE_REFLECT: u32 = 12;
/// Turns without a new success before parking the run.
pub const STALE_HALT: u32 = 150;
pub const STALE_HALT: u32 = 30;
/// Coaching has to stay rare enough that the model actually reads it.
pub const MAX_REFLECTIONS: u32 = 8;
@ -229,6 +230,7 @@ pub struct LoopGuard {
checkpoint_turn: u32,
soft_wall_spoken: bool,
reflections: u32,
recovery_notes: Vec<String>,
}
impl LoopGuard {
@ -256,6 +258,7 @@ impl LoopGuard {
checkpoint_turn: 0,
soft_wall_spoken: false,
reflections: 0,
recovery_notes: Vec::new(),
}
}
@ -263,6 +266,35 @@ impl LoopGuard {
self.policy
}
/// A stalled method gets a bounded opportunity to replan in this run.
/// Called only after fresh readback establishes changed state.
pub fn confirm_progress(&mut self, turn: u32) {
self.recovery_notes.clear();
self.last_progress_turn = turn;
}
/// Repeating the same exhausted method is not a new recovery strategy.
pub fn recover_or_halt(&mut self, verdict: Verdict, turn: u32) -> Verdict {
let Verdict::Halt { reason: StopReason::LoopDetected, note } = &verdict else {
return verdict;
};
if self.recovery_notes.len() >= 2 || self.recovery_notes.contains(note) {
return Verdict::Halt {
reason: StopReason::LoopDetected,
note: format!("已嘗試調整方法,仍無法推進。先前障礙:{};目前障礙:{note}", self.recovery_notes.join("")),
};
}
let mut notes = self.recovery_notes.clone();
notes.push(note.clone());
let instruction = format!(
"The current approach stalled: {note}. Previous recovery evidence: {}. Keep the original goal and completed work. Inspect fresh state and errors, identify the cause, and switch to a materially different supported method. Briefly explain what failed, what you actually tried, and your next approach in the user's language, then call tools now. Do not ask whether to continue or tell the human to debug. Only a confirmed human-only requirement warrants [GOAL_BLOCKED]. Never replay an uncertain mutation.",
notes.join("; ")
);
*self = Self::with_watch(self.policy, turn);
self.recovery_notes = notes;
Verdict::Reflect(instruction)
}
/// Called once per model turn, before the model is asked for anything.
pub fn on_turn(&mut self, turns: u32, elapsed: Duration) -> Verdict {
if turns >= self.policy.hard_cap_turns {
@ -300,6 +332,7 @@ impl LoopGuard {
/// Called after every tool result.
pub fn on_action(&mut self, action: &ActionObserved<'_>) -> Verdict {
if action.name == "report_task" { self.acted = true; return Verdict::Continue; }
let key = signature(action.name, action.args);
if self.last_key.as_deref() == Some(key.as_str()) {
self.repeat_streak = self.repeat_streak.saturating_add(1);
@ -352,6 +385,8 @@ impl LoopGuard {
}
self.any_failure_streak = 0;
// An accepted action is not proof of changed state. Recovery ends only
// after the caller observes new evidence (confirm_progress).
self.failures.remove(&key);
self.warned_failure = false;
self.acted = true;
@ -453,8 +488,9 @@ fn wall_text(minutes: u64, hard_minutes: u64) -> String {
format!(
"Time check: {minutes} minutes on this task. If the work is genuinely long (a build, \
a download, a queue), say so in one line and continue with a tool. If you are stuck, \
stop guessing: state what you have tried and what you need, then end with a \
standalone [NEEDS_INPUT] line. Nothing stops at {hard_minutes} minutes except you."
diagnose the failed approach and try a different supported route. Ask for human \
input only for a confirmed human-only requirement. The last-resort circuit breaker \
is {hard_minutes} minutes; elapsed time alone is not a reason to ask for help."
)
}
@ -491,6 +527,30 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn stalled_methods_replan_but_cannot_retry_the_same_failure_forever() {
let mut guard = LoopGuard::new(RunPolicy::default());
let stall = || Verdict::Halt { reason: StopReason::LoopDetected, note: "click failed".into() };
assert!(matches!(guard.recover_or_halt(stall(), 3), Verdict::Reflect(_)));
assert!(guard.recover_or_halt(stall(), 6).is_halt());
// Acceptance alone is insufficient; fresh changed evidence ends recovery.
guard.on_action(&act("exec", &json!({"argv":["build"]}), 7, true, true));
assert!(guard.recover_or_halt(stall(), 8).is_halt());
guard.confirm_progress(9);
assert!(matches!(guard.recover_or_halt(stall(), 10), Verdict::Reflect(_)));
}
#[test]
fn recovery_preserves_breakers_and_bounds_distinct_failed_strategies() {
let mut guard = LoopGuard::new(RunPolicy::default());
for (turn, note) in [(3,"first method"),(6,"second method")] {
assert!(matches!(guard.recover_or_halt(Verdict::Halt { reason: StopReason::LoopDetected, note:note.into() },turn),Verdict::Reflect(_)));
}
assert!(guard.recover_or_halt(Verdict::Halt { reason: StopReason::LoopDetected, note:"third method".into() },9).is_halt());
let breaker = guard.on_turn(HARD_CAP_TURNS, Duration::ZERO);
assert!(matches!(guard.recover_or_halt(breaker,HARD_CAP_TURNS),Verdict::Halt { reason:StopReason::BudgetExhausted,.. }));
}
fn act<'a>(
name: &'a str,
args: &'a Value,
@ -671,7 +731,7 @@ mod tests {
guard.on_action(&act("shell", &first, 1, true, true));
let eye = json!({});
let mut reflected = 0;
for turn in 2..=150 {
for turn in 2..=2 + STALE_HALT {
guard.on_action(&act("computer_observe", &eye, turn, true, false));
if matches!(
guard.on_turn(turn, Duration::from_secs(10)),
@ -681,7 +741,11 @@ mod tests {
}
}
assert!(reflected >= 1, "the run should be asked to step back");
assert!(guard.on_turn(152, Duration::from_secs(10)).is_halt());
assert!(
guard
.on_turn(2 + STALE_HALT, Duration::from_secs(10))
.is_halt()
);
}
#[test]

View File

@ -46,6 +46,15 @@ impl FakeSandbox {
pub fn host_spawn_count(&self) -> u32 {
*self.host_spawns.lock().unwrap()
}
pub fn insert_file(&self, home_key: &str, path: &str, bytes: Vec<u8>) {
self.files
.lock()
.unwrap()
.entry(home_key.to_string())
.or_default()
.insert(path.to_string(), bytes);
}
}
#[async_trait]
@ -60,11 +69,12 @@ impl SandboxProvider for FakeSandbox {
.unwrap()
.entry(request.home_key.clone())
.or_default();
let provider = format!("fake-{}", request.home_key);
Ok(ComputerRef {
id: format!("fake-{}", request.home_key),
id: provider.clone(),
home_key: request.home_key,
kind: SandboxKind::Docker,
provider_ref: format!("fake-{}", request.home_path),
provider_ref: provider,
fresh: true,
})
}
@ -341,6 +351,11 @@ impl SandboxProvider for FakeSandbox {
})
}
async fn stat_file(&self,computer:&ComputerRef,path:&str,context:&AdapterContext)->Result<serde_json::Value,SandboxError> {
let bytes=self.read_file(computer,path,context).await?;
Ok(serde_json::json!({"kind":"file","size":bytes.len()}))
}
async fn list_files(
&self,
computer: &ComputerRef,

11
docker-compose.web.yml Normal file
View File

@ -0,0 +1,11 @@
# Live frontend on :3101 without rebuilding the API image.
# The API container serves whatever is in apps/web/dist.
#
# npm --prefix apps/web run watch
# docker compose -f docker-compose.yml -f docker-compose.web.yml up -d api
#
# Prefer `make web-dev` (http://127.0.0.1:5173) when you want hot reload.
services:
api:
volumes:
- ./apps/web/dist:/web:ro

View File

@ -14,6 +14,16 @@
## 輪數不是額度,是偵錯工具
### 交代一次,持續執行
清楚的工作指令直接開工,不預設要求使用者確認計畫。模型請求不設 60 秒總時限,也不定時發送等待提示。首次動作最多補上一句開工說明,之後只在重要階段、改變方法或遇到阻礙時簡短回報;一般工具呼叫不逐一播報。實際發生模型重試時更新同一個狀態位置,新的輸出會清除提示。
Worker 在模型與工具等待期間,每 10 秒更新背景心跳並延長執行租約,不產生聊天訊息。綠點以最近 30 秒內的心跳為依據,失去心跳時前端不能用舊的串流文字覆蓋失聯狀態。綠點表示 worker 存活,不聲稱任務已有進展。真實的供應商錯誤仍會進入恢復流程;使用者仍能停止或接管。
一般任務也採用 goal 的交付原則完成子步驟或說「做好了」不會直接結束必須核對完整目標後明確宣告完成。普通的「做不到無法完成」會進入自行診斷而非直接要求使用者接手。模型須自行選擇除錯方法說明實際嘗試與結果後繼續登入、2FA、拼圖驗證、缺少必要資訊或授權才交給使用者。
偵測到方法打轉時,系統先要求換方法,保留原目標和工具歷史。同一段沒有進展的恢復最多提供兩次改道機會,相同失敗不能重新算一次;新的成功狀態變更才結束這段恢復。最後的保險上限仍有效。恢復耗盡會記為失敗並說明具體障礙,不標成完成。成功工具操作會重設連續空談次數,避免長任務累積幾次進度說明就被停止。執行中補充訊息會併入原任務,不必使用 `/goal`,也不另開一個工作搶同一台電腦。
以前一個 run 固定 40 輪,等於假設所有任務一樣長:洗資料這種事情做不完,但也不會因此變成
錯誤,只是被腰斬。現在的正常結局是**把事情做完**(模型提出驗證,或明白說它卡在哪裡),會
被停下來的只有鬼打牆:同一個會改變狀態的動作連做六次、同一個錯誤連錯八次、連續十四個動作
@ -27,6 +37,30 @@
純聊天仍然是 4 輪上限:那是避免模型在閒聊裡燒額度,跟任務長度無關。
## 任務狀態與交付
一般工作以 `report_task` 記錄重要進展、改採的方法、必要資訊與完成結果。回覆中提到
captcha 或說「做好了」不會改變工作狀態。完成回報必須列出已完成項目、驗證依據、空的
待辦清單與全部交付檔案;後端會檢查檔案範圍、存在、大小及讀取權限。這能驗證交付物
可取得,內容是否充分符合目標仍依賴模型提供的核對證據,並非自動判定所有事實正確。
`GET /api/sessions/{id}/task` 回傳該對話各助理最新任務、回報與心跳到期時間,沿用對話
權限檢查。狀態列區分排隊、處理、恢復、等人、完成、未完成與停止。綠點表示有效心跳;
電腦是否開機另行呈現。心跳、模型重試和逐次工具動作不新增聊天訊息;重要回報才進對話,
技術紀錄留在執行詳情。排隊及舊串流文字不能把助理冒充成在線。
交付回報通過後直接完成,不額外等待一輪模型回覆;完整核對證據留在可展開的詳情。
`request_takeover` 可指定登入、驗證或授權原因。介入卡只要求當下的人類步驟,交還後以
原 run 接續,重新取得畫面並附上先前工作紀錄,不要求再打一遍「繼續」。
成果卡沿用工作區預覽與下載;舊回覆仍可閱讀,未驗證的舊路徑不會補標成已交付。
一般介面以對話、成果及助理設定為主。助理設定最上方直接呈現完整頭像編輯器,
下方可編輯工作內容與工作指示。記憶、帳號與服務集中在設定,環境選擇、套件安裝及技能匯入收進進階入口。
既有會議模式偏好及獨立電腦設定保留,不搬動帳號或工作區資料。
回歸驗證:`tests/task-experience.test.mjs` 在獨立 API、資料庫及模擬模型下驗證一次交辦、
失敗改道與接手後原任務接續;不能拿它當作第三方網站或真實模型的成功率保證。
## 終端機是一台活的 tmux
`shell` 透過 Cua 在共用 VNC 桌面上開啟有名稱的終端機。相同 `session` 保留工作目錄、
@ -78,8 +112,8 @@ curl -N -b cookies.txt http://127.0.0.1:3101/api/sessions/<id>/events
## 已知取捨
- 模型回覆還是「整個完成」才出現,沒有串流 token。思考中的狀態有顯示但要像 ChatGPT 那樣逐字
冒出,需要把 completion 換成串流並處理中斷/接續,這是下一件事
- 一般聊天可串流文字;工作任務採安靜執行,只顯示重要進展與最終交付。狀態與心跳獨立更新,
不以逐字輸出或工具播報充當進度
- 電腦狀態(執行中/暫停、誰在控制)沒有自己的事件,只能靠兜底輪詢更新,`LIVE_POLL_MS` 目前是 4 秒;
桌面畫面本身是即時串流,只有狀態列會慢幾秒。要真正事件化得把 `computers` 的狀態變更也寫進
`events`(狀態是 bot 層、事件是執行緒層,要先決定寫到哪條執行緒)。

View File

@ -10,12 +10,20 @@ make dev-supervisor # 終端 1Supervisor :7091
make dev-api # 終端 2API :3101
```
前端熱更新:
前端熱更新(改 CSSReact **不要** `make up --build` 或重建 API 映像)
```bash
cd apps/web
npm install
npm run dev # http://127.0.0.1:5173
# API 已在 :3101 跑著即可Docker 或 make dev-api
make web-dev # http://127.0.0.1:5173
```
Vite 會把 `/api``/view` 轉到 `:3101`,存檔即熱更新。請開 **5173**,不要開 3101——3101 仍是上次打進映像/`dist` 的靜態檔。
若一定要繼續用 http://127.0.0.1:3101改掛 `dist`、不要重建映像:
```bash
make web-watch
docker compose -f docker-compose.yml -f docker-compose.web.yml up -d api
```
### 檢查與測試

View File

@ -269,7 +269,7 @@ start_vnc() {
local log="$4"
if ! port_open "$vnc_port"; then
x11vnc -display "$display" -forever -shared -nopw -listen 127.0.0.1 -rfbport "$vnc_port" \
-xkb -repeat -cursor arrow -noxdamage -ncache 0 >>"${log}-x11vnc.log" 2>&1 9>&- &
-clipboard -xkb -repeat -cursor arrow -noxdamage -ncache 0 >>"${log}-x11vnc.log" 2>&1 9>&- &
fi
if ! port_open "$view_port"; then
local novnc=/usr/share/novnc

View File

@ -0,0 +1,4 @@
-- One planning pause per conversation. After the bot has shown a plan (or
-- started work), later messages in the same thread skip the plan gate.
ALTER TABLE threads
ADD COLUMN IF NOT EXISTS plan_shown BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -18,16 +18,22 @@ emit('reply.started',3,{runId:'b',botId:'bot-b',generation:'parallel'});
emit('reply.delta',4,{runId:'b',generation:'parallel',text:'另一位'});
emit('reply.started',5,{runId:'a',botId:'bot-a',generation:'retry'});
emit('reply.delta',6,{runId:'a',generation:'first',text:'late failed attempt'});
assert.equal(state.a.text,'');assert.equal(state.b.text,'另一位');
assert.equal(state.a.text,'你好','already-shown text is never blanked');assert.equal(state.b.text,'另一位');
emit('reply.delta',7,{runId:'a',generation:'retry',text:'重試回覆'});
emit('reply.reset',8,{runId:'a',generation:'first'});assert.equal(state.a.text,'重試回覆');
emit('run.paused',9,{runId:'a'});assert.equal(state.a,undefined);
assert.equal(state.a.text,'你好\n\n重試回覆');
emit('reply.reset',8,{runId:'a',generation:'first'});assert.equal(state.a.text,'你好\n\n重試回覆');
emit('run.paused',9,{runId:'a'});assert.equal(state.a.text,'你好\n\n重試回覆','shown text is kept when the run parks');
emit('message.created',10,{runId:'b',role:'user',id:'steering',body:'keep going'});assert.equal(state.b.text,'另一位');
emit('message.created',11,{runId:'b',role:'assistant',id:'answer',body:'完整回覆'});assert.equal(state.b.messageId,'answer');assert.equal(state.b.text,'完整回覆');
emit('message.created',11,{runId:'b',role:'assistant',id:'answer',body:'完整回覆'});assert.equal(state.b.messageId,'answer');assert.equal(state.b.text,'另一位\n\n完整回覆');
emit('run.completed',12,{runId:'b'});assert.equal(state.b.messageId,'answer','keep final text until transcript fetch catches up');
emit('reply.started',13,{runId:'c',botId:'bot-c',generation:'last'});
emit('session.cleared',14,{});assert.deepEqual(state,{});
emit('reply.started',15,{runId:'stop',botId:'bot-a',generation:'stopping'});
emit('reply.delta',16,{runId:'stop',generation:'stopping',text:'partial'});
emit('run.cancelled',17,{runId:'stop'});assert.deepEqual(state,{});
emit('run.cancelled',17,{runId:'stop'});assert.equal(state.stop.text,'partial','stop does not yank tokens that already rendered');
emit('reply.started',18,{runId:'quiet',botId:'bot-a',generation:'task',quiet:true});
emit('tool.started',19,{runId:'quiet',step:'點頁面上的按鈕'});
assert.equal(state.quiet.text,'','tool activity does not become a chat message');
emit('message.created',20,{runId:'quiet',role:'assistant',id:'milestone',body:'第一份資料已核對。'});
assert.equal(state.quiet.text,'第一份資料已核對。','explicit milestones remain visible');
feed.close();console.log('Reply stream: replay, retry isolation, concurrent agents, pause, final message and clear passed');

View File

@ -29,16 +29,46 @@ test('unknown cron is preserved verbatim instead of silently rewritten',()=>{
for(const cron of ['0 0 9 * * 1','0 25 * * *','99 9 * * *','0 0 */3 * *','*/45 * * * *']){const p=presetFromCron(cron);assert.equal(p.freq,'Advanced');assert.equal(cronFromPreset(p),cron);}
});
test('blank advanced expressions are never converted to a scheduled job',()=>assert.equal(cronFromPreset({...defaultCronPreset(),freq:'Advanced',cron:''}),''));
test('VNC paste delegates once to confirmed backend and rejects another source',async()=>{
function loadViewer(clipboard, extras={}){
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/<script type="module">([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
const handlers={},sent=[];class RFB{addEventListener(){} focus(){} sendKey(){}}
const handlers={},sent=[],keys=[],pasted=[],scheduled=[];
class RFB{
constructor(){this.viewOnly=false;this._handlers={}}
addEventListener(name,handler){(this._handlers[name]||(this._handlers[name]=[])).push(handler)}
focus(){}
sendKey(...args){keys.push(args)}
clipboardPasteFrom(text){pasted.push(text)}
}
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent:{postMessage:x=>sent.push(x)},addEventListener:(n,f)=>handlers[n]=f};
vm.runInNewContext(source,{window,document:{location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:()=>({}),querySelector:()=>null},navigator:{clipboard:{readText:async()=>'中文\nhello'}},RFB,setTimeout(){},clearTimeout(){}});
vm.runInNewContext(source,{window,document:{location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:()=>({}),querySelector:()=>null},navigator:{clipboard},RFB,setTimeout:(fn,ms)=>{scheduled.push({fn,ms});return scheduled.length},clearTimeout(){},Date,console,...extras});
return {handlers,sent,keys,pasted,scheduled,window};
}
test('VNC paste delegates once to confirmed backend and rejects another source',async()=>{
const {handlers,sent}=loadViewer({readText:async()=>'中文\nhello'});
await handlers.keydown({ctrlKey:true,code:'KeyV',preventDefault(){},stopImmediatePropagation(){}});
assert.equal(sent.filter(x=>x.type==='lazyboy-paste-text').length,1);
assert.equal(sent.at(-1).text,'中文\nhello');
handlers.message({origin:'http://localhost',source:{},data:{type:'lazyboy-host-clipboard',text:'bad'}});assert.equal(sent.at(-1).text,'中文\nhello');
});
test('short VNC paste stays on the RFB session so a Cua stall cannot swallow it',async()=>{
const {handlers,sent,pasted,scheduled}=loadViewer({readText:async()=>'hello'});
await handlers.keydown({ctrlKey:true,code:'KeyV',preventDefault(){},stopImmediatePropagation(){}});
assert.equal(sent.filter(x=>x.type==='lazyboy-paste-text').length,0);
assert.deepEqual(pasted,['hello']);
assert.ok(scheduled.some(item=>item.ms===100),'x11vnc needs a beat before Shift+Insert');
});
test('Cmd+C copies on the desktop and asks the host to sync',async()=>{
const {handlers,sent,keys}=loadViewer({});
await handlers.keydown({metaKey:true,code:'KeyC',preventDefault(){},stopImmediatePropagation(){}});
assert.equal(sent.filter(x=>x.type==='lazyboy-copy-request').length,1);
assert.ok(keys.length>=2,'Ctrl+Insert is sent before the host copy request');
});
test('a failed backend paste can still inject through the viewer',()=>{
const {handlers,sent,pasted,window}=loadViewer({readText:async()=>''});
handlers.message({origin:'http://localhost',source:window.parent,data:{type:'lazyboy-host-clipboard',text:'hello'}});
assert.deepEqual(pasted,['hello']);
assert.equal(sent.filter(x=>x.type==='lazyboy-paste-text').length,0);
});
@ -53,7 +83,7 @@ const mdBox={exports:{},require:(name)=>{
throw new Error('unexpected import '+name);
}};
vm.runInNewContext(mdJs,mdBox);
const {sanitizeMarkdownUrl}=mdBox.exports;
const {sanitizeMarkdownUrl,isWorkspaceFileUrl}=mdBox.exports;
const audioJs=ts.transpileModule(fs.readFileSync('apps/web/src/call-audio.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const audioBox={exports:{},require:()=>{throw new Error('unexpected import')}};
vm.runInNewContext(audioJs,audioBox);
@ -77,13 +107,27 @@ test('pcm16 roundtrip keeps amplitude sign and resample identity at 24 kHz',()=>
const down=resample(new Float32Array([0,1,0,1]),48000,24000);
assert.equal(down.length,2);
});
test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
test('markdown links only keep http(s), mailto, tel, in-page hashes, and workspace files',()=>{
assert.equal(sanitizeMarkdownUrl('https://example.com/docs'),'https://example.com/docs');
assert.equal(sanitizeMarkdownUrl('mailto:hi@example.com'),'mailto:hi@example.com');
assert.equal(sanitizeMarkdownUrl('#section'),'#section');
assert.equal(sanitizeMarkdownUrl('javascript:alert(1)'),undefined);
assert.equal(sanitizeMarkdownUrl('data:text/html,<script>alert(1)</script>'),undefined);
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
assert.equal(sanitizeMarkdownUrl('notes.md'),'notes.md');
assert.equal(sanitizeMarkdownUrl('shared/plan.md'),'shared/plan.md');
assert.equal(sanitizeMarkdownUrl('../etc/passwd'),undefined);
assert.equal(sanitizeMarkdownUrl('foo/./bar'),undefined);
assert.equal(isWorkspaceFileUrl('inbox/photo.png'),true);
assert.equal(isWorkspaceFileUrl('javascript:alert(1)'),false);
});
test('chat overflow stays inside the bubble instead of the transcript pane',()=>{
const chat=fs.readFileSync('apps/web/src/chat.css','utf8');
const markdown=fs.readFileSync('apps/web/src/markdown.css','utf8');
assert.match(chat,/\.messages\{[^}]*overflow-x:hidden/);
assert.match(chat,/\.message\.assistant\.spoken \.message-time\{[^}]*position:static/);
assert.match(markdown,/\.md-pre-wrap\{[^}]*max-width:100%/);
});
test('zh-TW and en catalogs share keys and placeholders',()=>{
@ -280,7 +324,7 @@ test('status sits outside the remote pixels; screenshots stay opt-in',()=>{
assert.doesNotMatch(app,/RunStatus/);
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}><Avatar/);
assert.match(app,/--visible-height/);
assert.match(app,/<div className=\{`composer-dock \$\{statusMembers\.length\?"has-status":""\}`\}>\s*\{error&&<div className="error-banner"/);
assert.match(app,/<div className=\{`composer-dock \$\{[^`]+\}`\}>\s*\{error&&<div className="error-banner"/);
const css=fs.readFileSync('apps/web/src/computer.css','utf8');
assert.doesNotMatch(css,/\.run-status/);
const chat=fs.readFileSync('apps/web/src/chat.css','utf8');
@ -406,6 +450,108 @@ test('meeting mode is a desktop layout switch with a toggle in the title bar',()
assert.match(computerCss,/\.meeting-stage\{display:none!important\}/);
});
test('vite rewrites the proxied origin so local HMR is not treated as a foreign site',()=>{
const vite=fs.readFileSync('apps/web/vite.config.ts','utf8');
assert.match(vite,/setHeader\("origin"/);
assert.match(vite,/removeHeader\("sec-fetch-site"\)/);
const auth=fs.readFileSync('crates/api/src/auth.rs','utf8');
assert.match(auth,/fn loopback_dev_origin/);
assert.match(auth,/Some\(5173\), Some\(3101\)/);
});
test('compaction stays off the chat and multi-step work asks before booting',()=>{
const runs=fs.readFileSync('crates/api/src/runs.rs','utf8');
assert.doesNotMatch(runs,/對話太長,已把較舊的網頁快照/);
assert.match(runs,/fn needs_plan_first/);
assert.match(runs,/planning turn: tools and desktop withheld/);
assert.match(runs,/StopReason::NeedsPlan/);
assert.match(runs,/PLAN_INSTRUCTIONS/);
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/reason==="needs_plan"\?t\("resumePlan"\)/);
assert.match(app,/resumeStart/);
const rsJs=ts.transpileModule(fs.readFileSync('apps/web/src/reply-stream.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const rsBox={exports:{},require:(name)=>{if(name==='./live')return{}}};
vm.runInNewContext(rsJs,rsBox);
const {applyReplyEvent}=rsBox.exports;
const event=(kind,payload)=>({kind,id:1,payload});
let state=applyReplyEvent({},event('reply.started',{runId:'a',botId:'b',generation:'g1'}));
state=applyReplyEvent(state,event('tool.started',{runId:'a',step:'看畫面'}));
assert.equal(state.a.text,'正在看畫面');
state=applyReplyEvent(state,event('tool.started',{runId:'a',step:'點擊'}));
assert.equal(state.a.text,'正在看畫面','later tools do not replace a spoken line');
state=applyReplyEvent({},event('reply.progress',{runId:'b',botId:'bot',text:'正在打開搜尋頁'}));
assert.equal(state.b.text,'正在打開搜尋頁');
const live=fs.readFileSync('apps/web/src/live.ts','utf8');
assert.match(live,/reply\.progress/);
assert.match(runs,/fn should_gate_on_plan/);
assert.match(runs,/fn pause_for_human/);
assert.match(runs,/plan_shown/);
assert.match(runs,/run failed after recovery was exhausted/);
});
test('the presence dot is a live pulse, not an always-on badge',()=>{
const avatar=fs.readFileSync('apps/web/src/avatar.tsx','utf8');
assert.match(avatar,/\{dot !== "idle" && <i className=\{`presence is-\$\{dot\}`\}/);
assert.doesNotMatch(avatar,/online \|\| active/);
const css=fs.readFileSync('apps/web/src/avatar.css','utf8');
assert.match(css,/presence-pulse/);
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/function botPresenceMap/);
assert.match(app,/presence=\{presenceById\[bot\.id\]\}/);
const bot=fs.readFileSync('crates/contracts/src/bot.rs','utf8');
assert.match(bot,/pub working: bool/);
});
test('only a renewed lease may pulse; a heartbeat-less run never reads as alive',()=>{
// The dot is the whole promise of "it is still working", so a run whose
// worker vanished has to look different from one that is making progress.
const bot=fs.readFileSync('crates/contracts/src/bot.rs','utf8');
assert.match(bot,/pub fn run_presence\(/);
assert.match(bot,/Some\(expiry\) if expiry > now => BotPresence::Working/);
assert.match(bot,/_ => BotPresence::Stalled/);
assert.match(bot,/RunStatus::Queued => BotPresence::Idle/);
assert.match(bot,/WaitingInput \| RunStatus::WaitingTakeover => BotPresence::Waiting/);
// The API must read the lease, not just the status, or a dead worker keeps
// its bot green until someone reaps the run.
const routes=fs.readFileSync('crates/api/src/routes.rs','utf8');
assert.match(routes,/LEAST\(r\.lease_expires_at/);
assert.match(routes,/heartbeatAt/);
assert.match(routes,/working: state == BotPresence::Working/);
const css=fs.readFileSync('apps/web/src/avatar.css','utf8');
const declaration=name=>{
const match=css.match(new RegExp(`\\.presence\\.is-${name}\\{([^}]*)\\}`));
assert.ok(match,`missing .presence.is-${name} rule`);
return match[1];
};
assert.match(declaration('working'),/animation:presence-pulse/);
assert.doesNotMatch(declaration('waiting'),/animation/);
assert.doesNotMatch(declaration('stalled'),/animation/);
assert.match(declaration('stalled'),/background:transparent/);
// Live evidence may promote a bot to working, and stale poll data may not
// keep a finished run green.
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/const stale=reported==="working"&&watched\.has\(bot\.id\)&&!liveWorking\.has\(bot\.id\)/);
assert.match(app,/if\(map\[id\]!=="stalled"&&map\[id\]!=="waiting"\) map\[id\]="working"/);
});
test('the conversation list can collapse so meeting chat has room',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/leftCollapsed\?"left-collapsed":""/);
assert.match(app,/function toggleNav/);
assert.match(app,/className="icon-button nav-toggle"/);
assert.match(app,/navCollapsed:leftCollapsed/);
const layout=fs.readFileSync('apps/web/src/refinements.css','utf8');
assert.match(layout,/\.app-shell\.meeting-mode\.left-collapsed/);
assert.match(layout,/grid-template-areas:"stage chat"/);
assert.match(layout,/minmax\(380px,42vw\)/);
assert.match(layout,/\.app-shell\.left-collapsed \.mobile-menu\{display:inline-flex\}/);
const css=fs.readFileSync('apps/web/src/responsive.css','utf8');
assert.match(css,/nav-toggle\{display:none!important\}/);
});
test('chat messages use a copy-or-reply menu instead of inline run details',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/function MessageContextMenu/);
@ -443,6 +589,75 @@ const liveJs=ts.transpileModule(fs.readFileSync('apps/web/src/live.ts','utf8'),{
const liveBox={exports:{}};vm.runInNewContext(liveJs,liveBox);
const {sessionEventsUrl,subscribeToSession,SESSION_EVENT_TYPES,createCoalescer}=liveBox.exports;
test('waiting status replaces itself without entering the reply and clears on output',()=>{
const code=ts.transpileModule(fs.readFileSync('apps/web/src/reply-stream.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const box={exports:{}};vm.runInNewContext(code,box);
const {applyReplyEvent,shownReplyText}=box.exports;
const event=(kind,payload)=>({kind,payload:{runId:'r',botId:'b',...payload}});
let state=applyReplyEvent({},event('reply.started',{generation:'g'}));
for(let i=0;i<5;i++)state=applyReplyEvent(state,event('reply.progress',{statusOnly:true,text:'仍在處理'}));
assert.equal(state.r.text,'');
assert.equal(state.r.status,'仍在處理');
state=applyReplyEvent(state,event('reply.started',{generation:'g'}));
assert.equal(state.r.status,'仍在處理','a retry keeps its explanation until actual output arrives');
state=applyReplyEvent(state,event('reply.delta',{generation:'g',text:'完成第一步'}));
assert.equal(state.r.status,undefined);
assert.equal(state.r.text,'完成第一步');
state=applyReplyEvent(state,event('reply.progress',{statusOnly:true,text:'等待下一步'}));
state=applyReplyEvent(state,event('run.completed',{}));
assert.equal(state.r.status,undefined);
assert.equal(shownReplyText({id:'m',body:'成果'},state),'成果');
});
test('streamed reply text is never blanked when the next generation starts',()=>{
const rsJs=ts.transpileModule(fs.readFileSync('apps/web/src/reply-stream.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const rsBox={exports:{},require:(name)=>{if(name==='./live')return{}}};
vm.runInNewContext(rsJs,rsBox);
const {applyReplyEvent,shownReplyText,visibleReplyDrafts}=rsBox.exports;
const event=(kind,payload)=>({kind,id:1,payload});
let state=applyReplyEvent({},event('reply.started',{runId:'a',botId:'b',generation:'g1'}));
state=applyReplyEvent(state,event('reply.delta',{runId:'a',generation:'g1',text:'先搜尋 Threads'}));
state=applyReplyEvent(state,event('reply.started',{runId:'a',botId:'b',generation:'g2'}));
assert.equal(state.a.text,'先搜尋 Threads');
state=applyReplyEvent(state,event('reply.delta',{runId:'a',generation:'g2',text:'做好了。'}));
assert.equal(state.a.text,'先搜尋 Threads\n\n做好了。');
state=applyReplyEvent(state,event('message.created',{runId:'a',role:'assistant',id:'m1',body:'做好了。'}));
assert.equal(state.a.text,'先搜尋 Threads\n\n做好了。');
const earlier={id:'pause',runId:'a',role:'assistant',body:'先停在這裡。'};
assert.equal(shownReplyText(earlier,state),'先停在這裡。');
assert.equal(visibleReplyDrafts(state,[earlier]).length,1,'an older pause on the same run must not swallow live tokens');
const message={id:'m1',runId:'a',role:'assistant',body:'做好了。'};
assert.equal(shownReplyText(message,state),'先搜尋 Threads\n\n做好了。');
assert.equal(visibleReplyDrafts(state,[message]).length,0);
state=applyReplyEvent(state,event('run.completed',{runId:'a'}));
assert.equal(state.a.text,'先搜尋 Threads\n\n做好了。');
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/shownReplyText\(message,replyDrafts\)/);
assert.match(app,/visibleReplyDrafts\(replyDrafts,messages\)/);
});
test('a finished reply draft does not keep the thinking avatar spinning',()=>{
const rsJs=ts.transpileModule(fs.readFileSync('apps/web/src/reply-stream.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const rsBox={exports:{},require:(name)=>{if(name==='./live')return{}}};
vm.runInNewContext(rsJs,rsBox);
const {replyStillStreaming}=rsBox.exports;
assert.equal(replyStillStreaming({a:{runId:'a',botId:'b',generation:'g',text:'hi'}}),true);
assert.equal(replyStillStreaming({a:{runId:'a',botId:'b',generation:'g',text:'hi',messageId:'m1'}}),false);
assert.equal(replyStillStreaming({}),false);
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/replyStillStreaming\(replyDrafts\)/);
assert.doesNotMatch(app,/Object\.keys\(replyDrafts\)\.length>0/);
});
test('reply tokens are pushed live instead of written to the events table',()=>{
const runs=fs.readFileSync('crates/api/src/runs.rs','utf8');
assert.match(runs,/wakes\.live\(\s*trace\.thread_id,\s*"reply\.delta"/);
assert.doesNotMatch(runs,/append_event\(\s*trace\.state,\s*trace\.thread_id,\s*"reply\.delta"/);
const state=fs.readFileSync('crates/api/src/state.rs','utf8');
assert.match(state,/pub fn live\(/);
assert.match(state,/live_frames_do_not_knock_the_durable_reader/);
});
test('the browser listens for every event kind the api can append',()=>{
const rust=['crates/api/src/runs.rs','crates/api/src/sessions.rs','crates/api/src/schedules.rs','crates/api/src/voice_call.rs'].map(file=>fs.readFileSync(file,'utf8')).join('\n');
const emitted=[...rust.matchAll(/["']((?:message|run|session)\.[a-z_]+)["']/g)].map(match=>match[1]);
@ -495,13 +710,40 @@ test('a burst of session events settles into one refresh',()=>{
test('chat follows the event stream instead of a fixed two second poll',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/subscribeToSession\(activeSessionId,event=>\{[^}]*settle\.kick\(\)\}/,'every non-streaming event kicks the settle coalescer');
assert.match(app,/createCoalescer\(\(\)=>\{if\(!document\.hidden\)refresh\(\)\.catch\(\(\)=>\{\}\)\},EVENT_SETTLE_MS\)/);
assert.match(app,/subscribeToSession\(activeSessionId,event=>\{[\s\S]*settle\.kick\(\)/,'every non-streaming event kicks the settle coalescer');
assert.match(app,/if\(event\.kind!=="tool\.started"&&!event\.kind\.startsWith\("reply\."\)\)settle\.kick\(\)/);
assert.match(app,/createCoalescer\(\(\)=>\{if\(!document\.hidden\)Promise\.all\(\[refreshTranscript\(\),refreshComputer\(\)\]\)\.catch\(\(\)=>\{\}\)\},EVENT_SETTLE_MS\)/);
assert.match(app,/\(live\?refreshComputer:refresh\)\(\)/);
assert.match(app,/document\.addEventListener\("visibilitychange",resume\)/);
assert.doesNotMatch(app,/const timer=setInterval\(\(\)=>\{refresh\(\)/,'the 2s transcript poll should be gone');
assert.match(app,/const heartbeat=window\.setInterval\(\(\)=>\{const beat=roomsRef\.current[\s\S]*\},HEARTBEAT_MS\)/,'the heartbeat keeps its own minute cadence');
});
test('the model prompt is a working set; the transcript keeps full assistant text',()=>{
const runs=fs.readFileSync('crates/api/src/runs.rs','utf8');
assert.match(runs,/fn thread_history_limit\(has_summary: bool\)/);
assert.match(runs,/fn model_facing_reply\(body: &str\)/);
assert.match(runs,/AssistantContent::text\(\s*model_facing_reply\(&body\)\.to_string\(\),?\s*\)/);
assert.match(runs,/merge_spoken\(/);
const fit=fs.readFileSync('crates/api/src/context_fit.rs','utf8');
assert.match(fit,/KEEP_HOT_OBSERVATIONS: usize = 2/);
assert.match(fit,/HOT_PART: usize = 8 \* 1024/);
assert.match(fit,/durable transcript is untouched/);
});
test('token deltas do not re-parse the whole transcript or re-read events',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/const onOpenFile=useCallback/);
assert.match(app,/onOpenFile=\{paneBot\?onOpenFile:undefined\}/);
assert.match(app,/onMention=\{onMention\}/);
assert.match(app,/scrollIntoView\(\{block:"end"\}\)/);
assert.doesNotMatch(app,/behavior:"smooth"/);
const state=fs.readFileSync('crates/api/src/state.rs','utf8');
assert.match(state,/live_frames_do_not_knock_the_durable_reader/);
assert.match(state,/Does not knock the/);
assert.doesNotMatch(state,/self\.live\.send\([\s\S]*?self\.wake\(thread_id\)/);
});
// The screen veil and the frame that survives it: both used to be driven by a
// timer plus "null the url", which is what made booting and handing over feel
// like a stall instead of a gesture.
@ -883,3 +1125,51 @@ test('file evidence button uses readback API and cancels work when its panel clo
await new Promise(resolve=>setImmediate(resolve));
});
const taskJs=ts.transpileModule(fs.readFileSync('apps/web/src/task-status.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const taskBox={exports:{},require:()=>({})};vm.runInNewContext(taskJs,taskBox);
test('task presence expires from server clock without inventing progress',()=>{
const {taskState}=taskBox.exports;
const base={status:'running',serverTime:'2026-09-12T00:00:00Z',aliveUntil:'2026-09-12T00:00:30Z',receivedAt:1000};
assert.equal(taskState(base,2000),'taskWorking');
assert.equal(taskState(base,31000),'taskDisconnected');
assert.equal(taskState({...base,aliveUntil:null},1000),'taskDisconnected');
assert.equal(taskState({...base,status:'completed'},2000),'taskEnded');
assert.equal(taskState({...base,status:'completed',report:{state:'complete'}},2000),'taskCompleted');
assert.equal(taskState({...base,status:'queued',aliveUntil:null},1000),'taskQueued');
assert.equal(taskState({...base,status:'waiting_takeover'},999999),'taskWaiting');
assert.equal(taskState({...base,status:'failed',report:{state:'complete'}},2000),'taskFailed');
assert.equal(taskState({...base,report:{state:'recovering'}},2000),'taskRecovering');
});
test('workspace deliverables support Chinese names but never path traversal',()=>{
assert.equal(mdBox.exports.isWorkspaceFileUrl('shared/蝦皮分潤攻略.md'),true);
assert.equal(mdBox.exports.isWorkspaceFileUrl('shared/../secret'),false);
assert.equal(mdBox.exports.isWorkspaceFileUrl('/etc/passwd'),false);
});
test('layout controls switch directly between meeting and side panels, and toggle closed',()=>{
const source=fs.readFileSync('apps/web/src/App.tsx','utf8');
const handlers=source.slice(source.indexOf(' function openPane(part:RightPart){'),source.indexOf(' function toggleNav()')).replace('part:RightPart','part');
function click(initial,action,phone=false){
const state={meetingMode:false,rightCollapsed:true,rightPart:'computer',computerOpen:false,mobileNav:false,...initial};
const keys=Object.keys(state);
const setters=keys.map(key=>'set'+key[0].toUpperCase()+key.slice(1));
const run=new Function(...keys,...setters,'isPhoneLayout',handlers+';'+action);
run(...keys.map(key=>state[key]),...keys.map(key=>value=>{state[key]=value}),()=>phone);
return state;
}
let state=click({},'toggleMeeting()');
assert.equal(state.meetingMode,true);
state=click(state,'openPane("computer")');
assert.equal(state.meetingMode,false);assert.equal(state.rightCollapsed,false);
state=click(state,'openPane("computer")');assert.equal(state.rightCollapsed,true);
state=click(state,'toggleMeeting()');
state=click(state,'openPane("settings")');
assert.equal(state.meetingMode,false);assert.equal(state.rightPart,'settings');assert.equal(state.rightCollapsed,false);
state=click(state,'openPane("results")');assert.equal(state.rightPart,'results');assert.equal(state.rightCollapsed,false);
state=click(state,'openPane("results")');assert.equal(state.rightCollapsed,true);
state=click(state,'toggleMeeting()');state=click(state,'toggleMeeting()');
assert.equal(state.meetingMode,false);assert.equal(state.rightPart,'computer');assert.equal(state.rightCollapsed,false);
state=click({},'openPane("computer")',true);assert.equal(state.computerOpen,true);
state=click(state,'openPane("settings")',true);assert.equal(state.computerOpen,false);assert.equal(state.rightCollapsed,false);
});

View File

@ -42,7 +42,7 @@ for fragment in [
"r.status='waiting_input'",
"r.status='waiting_takeover' AND NOT EXISTS",
"c.control_holder='user'",
"UPDATE runs SET status='queued', retry_count=0, checkpoint=checkpoint-'awaitResume', updated_at=now() WHERE id=$1 AND status IN ('waiting_input','waiting_takeover')",
"UPDATE runs SET status='queued', retry_count=0, checkpoint=checkpoint-'awaitResume'-'taskReport', updated_at=now() WHERE id=$1 AND status IN ('waiting_input','waiting_takeover')",
"SET status='waiting_input', lease_owner=NULL, lease_expires_at=NULL, updated_at=now(),",
'"kind": "resume"',
]:
@ -52,8 +52,7 @@ WAKE = """
SELECT r.id FROM runs r
WHERE r.bot_id=$BOT AND r.thread_id=$THREAD
AND (
(r.status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND btrim(r.prompt) ~ '^/goal($|[[:space:]])')
r.status IN ('queued','leased','running')
OR r.status='waiting_input'
OR (r.status='waiting_takeover' AND NOT EXISTS (
SELECT 1 FROM computers c JOIN bots b ON b.computer_id=c.id
@ -64,10 +63,12 @@ SELECT r.id FROM runs r
WAKE_RUN = """
UPDATE runs SET status='queued', retry_count=0,
checkpoint=checkpoint-'awaitResume', updated_at=now()
checkpoint=checkpoint-'awaitResume'-'taskReport', updated_at=now()
WHERE id=$1 AND status IN ('waiting_input','waiting_takeover')
"""
assert flatten(WAKE.replace('$BOT', '$1').replace('$THREAD', '$2')) in source, 'exercise the actual task-steering SQL'
def wake(bot, thread):
found = query(WAKE.replace('$BOT', f"'{bot}'").replace('$THREAD', f"'{thread}'"))
@ -112,7 +113,7 @@ try:
assert wake('b1','t1') == 'r_paused', 'a paused run must collect the next message'
assert wake('b2','t2') == '<null>', 'a run whose human holds control stays parked'
assert wake('b3','t3') == 'r_takeover_free', 'a released takeover run resumes on reply'
assert wake('b4','t4') == '<null>', 'a plain running run starts its own task'
assert wake('b4','t4') == 'r_running', 'ordinary task steering joins the existing goal without restarting'
assert wake('b5','t5') == 'r_goal', 'goal steering still joins the live goal run'
assert wake('b6','t6') == '<null>', 'a finished run is never reopened'

View File

@ -0,0 +1,75 @@
/** Run against a disposable API with SANDBOX_PROVIDER=fake and a fresh DB.
* LAZYBOY_TEST_API=http://127.0.0.1:3118 node --test tests/task-experience.test.mjs
* The mock provider is reachable from the API at host.docker.internal:3119.
* Never point this test at a workspace containing real user data.
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {randomUUID} from 'node:crypto';
const base=process.env.LAZYBOY_TEST_API;
test('one assignment: verified completion, recovery, and same-run takeover resume',{skip:!base,timeout:60000},async()=>{
assert.equal(new URL(base).port,'3118','use the isolated test API');
let cookie='';const calls={};const histories={};
const report=(state,summary,remaining=[],extra={})=>({state,summary,completed:['Compared the two supplied source excerpts'],remaining,verification:'Checked both supplied excerpts and the requested comparison',artifacts:[],...extra});
const fixture=http.createServer(async(req,res)=>{
let body='';for await(const chunk of req)body+=chunk;
const input=JSON.parse(body||'{}');const serialized=JSON.stringify(input.messages||[]);
const scenario=['UX_RESEARCH','UX_RECOVERY','UX_TAKEOVER'].find(tag=>serialized.includes(tag));
if(!scenario){res.writeHead(400);res.end('unknown fixture');return;}
const index=calls[scenario]||0;calls[scenario]=index+1;(histories[scenario]??=[]).push(serialized);
let name,args,text='Finished.';
if(scenario==='UX_RESEARCH'){
if(index===0)text='I have only planned it.\n[GOAL_COMPLETE]';
if(index===1){name='report_task';args=report('progress','I checked the first excerpt.',['Compare the second excerpt']);}
if(index===2){name='report_task';args=report('complete','A and B both support delegation; B also requires verification.');}
}else if(scenario==='UX_RECOVERY'){
if(index===0){name='read_file';args={path:'missing-source.txt'};}
if(index===1){name='report_task';args=report('recovering','The file is missing. I will use the source excerpts you supplied.',['Compare the excerpts'],{attempts:['read_file returned not found; switching to supplied source text']});}
if(index===2){name='report_task';args=report('complete','Completed the comparison using the supplied sources.');}
}else{
if(index===0){name='request_takeover';args={intervention:'verification',site:'Local test fixture',reason:'Complete the human verification step.',why:'I will finish the original comparison.'};}
if(index===1){name='report_task';args=report('complete','Resumed and completed the original comparison.');}
}
const id=randomUUID();const choice=name?{role:'assistant',tool_calls:[{id,type:'function',function:{name,arguments:JSON.stringify(args)}}]}:{role:'assistant',content:text};
if(input.stream){
res.writeHead(200,{'content-type':'text/event-stream'});
const chunk=delta=>({id,object:'chat.completion.chunk',created:1,model:input.model,choices:[{index:0,delta,finish_reason:null}]});
res.write(`data: ${JSON.stringify(chunk(name?{role:'assistant',tool_calls:[{index:0,...choice.tool_calls[0]}]}:{role:'assistant',content:text}))}\n\n`);
res.write(`data: ${JSON.stringify({id,object:'chat.completion.chunk',created:1,model:input.model,choices:[{index:0,delta:{},finish_reason:name?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}})}\n\ndata: [DONE]\n\n`);res.end();
}else{res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({id,object:'chat.completion',created:1,model:input.model,choices:[{index:0,message:choice,finish_reason:name?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}));}
});
await new Promise(resolve=>fixture.listen(3119,'0.0.0.0',resolve));
async function api(path,body,method=body?'POST':'GET'){
const r=await fetch(base+path,{method,headers:{'content-type':'application/json',cookie},body:body?JSON.stringify(body):undefined});
if(r.headers.get('set-cookie'))cookie=r.headers.get('set-cookie').split(';')[0];
const raw=await r.text();assert.ok(r.ok,`${path}: ${r.status} ${raw}`);return raw?JSON.parse(raw):null;
}
async function until(fn,label){for(let i=0;i<150;i++){const found=await fn();if(found)return found;await new Promise(r=>setTimeout(r,150));}throw Error(`Timed out: ${label}`);}
try{
await api('/api/auth/register',{username:'ux'+randomUUID().replaceAll('-','').slice(0,12),password:'FixtureOnly123!'});
await api('/api/workspace/settings',{provider:'openai-compatible',modelId:'fixture-vision',baseUrl:'http://host.docker.internal:3119/v1',apiKey:'fixture-only'},'PATCH');
const bot=await api('/api/bots',{name:'UX fixture',computerMode:'team',memoryEnabled:false});
for(const scenario of ['UX_RESEARCH','UX_RECOVERY','UX_TAKEOVER']){
const session=await api(`/api/bots/${bot.id}/sessions`,{title:scenario});
await api(`/api/sessions/${session.id}/messages`,{text:`${scenario}: Compare these sources and deliver a concise report. Source A: assign the outcome once. Source B: assign the outcome once and verify the result. Use supplied excerpts if a file is missing.`,clientNonce:randomUUID()});
let run;
if(scenario==='UX_TAKEOVER'){
run=await until(async()=>{const [r]=await api(`/api/sessions/${session.id}/task`);return r?.status==='waiting_takeover'?r:null;},'takeover');
const messages=await api(`/api/sessions/${session.id}/messages`);
assert.equal(messages.flatMap(m=>m.blocks||[]).find(b=>b.kind==='login')?.intervention,'verification');
await api(`/api/computer/${bot.id}/release`,{});
}
const completed=await until(async()=>{const [r]=await api(`/api/sessions/${session.id}/task`);if(r?.status==='failed')throw Error(`run failed: ${scenario}`);return r?.status==='completed'?r:null;},scenario);
assert.equal(completed.report.state,'complete');assert.deepEqual(completed.report.remaining,[]);
if(run)assert.equal(completed.runId,run.runId,'human releases control; original run resumes');
const messages=await api(`/api/sessions/${session.id}/messages`);
assert.equal(messages.filter(m=>m.role==='user').length,1,'no repeated instruction');
assert.match(messages.at(-1).body,/comparison|delegation/);
if(scenario==='UX_RESEARCH')assert.equal(calls[scenario],3,'reject bare completion, then deliver without an extra model call');
if(scenario==='UX_RECOVERY')assert.equal(messages.filter(m=>m.blocks?.some(b=>b.kind==='progress')).length,1);
if(scenario==='UX_TAKEOVER')assert.match(histories[scenario][1],/CURRENT screen|current screen/);
console.log(`${scenario}: complete, one user assignment, ${run?'one verification step':'zero intervention'}`);
}
}finally{fixture.closeAllConnections();await new Promise(resolve=>fixture.close(resolve));}
});