feat/schedule #3

Merged
daniel.w merged 3 commits from feat/schedule into main 2026-09-05 15:19:29 +00:00
24 changed files with 2821 additions and 260 deletions
Showing only changes of commit 8790712d9b - Show all commits

264
README.md
View File

@ -134,118 +134,27 @@ Blobatar 色塊+眼睛。啟動、喚醒、連線、換手時,預覽左上
---
## 系統怎麼起來
## 系統怎麼起來
```text
瀏覽器
├─ React UIapps/web
└─ /view/{bot} → API 畫面代理cookie→ 容器 websockify → x11vnc → Xvfb
lazyboy-api
├─ Postgres + pgvector
├─ runs worker最多 16 條並行,每個 bot 同時一個)
├─ computer idle_loop熱機凍結停放
├─ schedules tick
├─ MCP hub
└─ harness選模型、接金鑰
▼ HTTPSANDBOX_SUPERVISOR_TOKEN
lazyboy-supervisor :7091
│ Docker socket
lazyboy/computer 容器
├─ start.sh → Xvfb :1 + XFCE + x11vnc + websockify
├─ lazyboy-screen 額外 Team 螢幕 :2…:8
└─ lazyboy-controld :7070 容器內觀察/動作
data/homes/<homeKey> bind 到 /home/lazyboy
```
![其實只有三個角色你、LazyBoy、它的電腦](./docs/diagrams/map.png)
Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervisor:7091`
Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervisor:7091`。家目錄 `data/homes/<homeKey>` bind 進容器的 `/home/lazyboy`
---
## 時序:你送一則訊息
## 你送一則訊息
```mermaid
sequenceDiagram
participant U as 瀏覽器
participant A as API
participant DB as Postgres
participant W as run worker
participant H as harness
participant M as 模型
participant S as supervisor
participant C as 桌面容器
![它先決定:聊就好,還是要動手](./docs/diagrams/chat.png)
U->>A: POST /api/sessions/{id}/messages
A->>DB: 寫 user 訊息clientNonce 去重)
A->>DB: INSERT run status=queued
A-->>U: 202 + 輪詢 messages / computer status
同一 bot 已有進行中的工作時,新訊息會排隊(`queuedBehindActive`)。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。問候路徑會把工具表清空,從源頭避免「哈囉」去開電腦。
loop 每 200ms
W->>DB: 租下一筆 queued同 bot 沒有別人在跑)
end
W->>H: resolve_backendbot → 工作區 → 環境變數)
H-->>W: provider + model + key
W->>M: 系統提示 + 歷史 + 記憶 + 工具定義
alt 純聊天(問候、閒聊、知識問答)
M-->>W: 純文字
W->>DB: 寫 assistant 訊息run=completed
else 要用電腦
M-->>W: tool_callbrowser / computer_act / shell…
W->>DB: busy_step電腦啟動中喚醒中實際動作
W->>A: boot() 若尚未 running
A->>S: provision 或 unpause
S->>C: 等 /tmp/lazyboy/ready
W->>C: 執行工具
C-->>W: 截圖DOMstdout
W->>M: tool result畫面變了才帶圖
M-->>W: 下一動或結束文字
end
W->>DB: completed放開螢幕租約
U->>A: GET statusmessages2 秒一次)
```
同一 bot 已有 `leased``running``waiting_takeover` 時,新訊息會 `queuedBehindActive`。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。
`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、技能 80。
---
## 時序:電腦從開機到你看到畫面
## 電腦的作息
```mermaid
sequenceDiagram
participant U as 瀏覽器
participant A as API
participant S as supervisor
participant D as Docker
participant C as 容器 PID 1
U->>A: POST /api/computer/{bot}/boot
alt 已是 running 且容器還在
A-->>U: state=running
else 休眠中docker pause
A->>S: POST /computers/{id}/unpause
S->>D: unpause
S->>C: 確認 /tmp/lazyboy/ready約一秒內
A-->>U: state=running
else 已關或沒有容器
A->>DB: state=booting
A->>S: POST /computers
S->>D: 找到可重用的就 start否則 create
D->>C: start.sh
C->>C: Xvfb :1、XFCE、x11vnc、websockify、controld
C->>C: touch /tmp/lazyboy/ready
A->>S: ensure_screenTeam 再掛 :2…
A-->>U: state=running
end
U->>A: GET /api/computer/{bot}/screen
A-->>U: /view/{bot}/vnc.html
U->>A: WebSocket /view/{bot}/websockify
A->>C: 轉到該 slot 的 6080+N
Note over U,C: 分頁每 2 秒 heartbeat更新 computers.updated_at
```
![開著、小睡、關機](./docs/diagrams/sleep.png)
閒置(`crates/api/src/computer.rs` `idle_loop`
@ -253,151 +162,57 @@ sequenceDiagram
2. 休眠超過 6 小時 → `docker stop`,狀態 `stopped`
3. 分頁還在就心跳,不會進 1
---
## Harness 流程
`crates/harness` 不管滑鼠,只負責**這次 run 要用哪一家模型**。真正的 agent 迴圈在 `crates/api/src/runs.rs`
```mermaid
flowchart TD
A[execute_run] --> B[讀 bot 與工作區設定]
B --> C[CredentialChain]
C --> C1[bot 自己存的 key]
C --> C2[工作區設定的 key]
C --> C3[環境變數 XAI_API_KEY 等]
C1 --> D[resolve_backend]
C2 --> D
C3 --> D
D --> E{provider}
E -->|xai| F[rig xAI CompletionModel]
E -->|opencode-go 且 gpt/grok/muse| G[OpenAI Responses API]
E -->|其他 OpenAI 相容| H[OpenAI Chat Completions]
F --> I[connect_model]
G --> I
H --> I
I --> J[帶工具定義進 complete_once]
J --> K{回傳}
K -->|純文字| L[聊天結束或 skill 檢查沒過就 nudge]
K -->|tool_calls| M[dispatch]
M --> N[結果寫回 history]
N --> J
```
金鑰優先順序:**bot → 工作區 → 環境變數**。API 跑在 Docker 時,迴圈位址 `127.0.0.1` 會被改成 `host.docker.internal`,才能打到你本機的相容端點。
`execute_run` 每一輪:
1. 續租約5 分鐘),否則 halt
2. 寫 `busy_step`思考中電腦啟動中browser click…
3. `complete_once`system + 記憶 + 技能目錄 + 歷史
4. 沒有 tool call問候就結束技能 run 若檢查沒過,把現在畫面塞回去再逼一次(最多數次)
5. 有 tool call需要沙盒才 `prepare_run_computer`boot解凍、拿螢幕執行租約、瀏覽器 profile lock
6. `dispatch` 跑工具,畫面沒變就不重複塞圖
7. 直到文字結束、halt停止接管、或達到回合上限聊天 4、一般 40、技能 80
問候路徑會把工具表清空,從源頭避免「哈囉」去 `ls` 家目錄。
開機/喚醒:已在跑就直接回;凍結中就 `unpause`(約一秒);沒有容器才 `provision`,等 `/tmp/lazyboy/ready`。畫面走 `/view/{bot}/vnc.html`,已登入的 cookie 轉到 websockify。
---
## 控制電腦的原理
## 它怎麼看、怎麼點
模型**從不**直接連 VNC。它只打 API 工具;工具經 sandbox HTTP 進 supervisor`docker exec` 或打容器內 `controld`
![你看乾淨的,它看有編號的](./docs/diagrams/look.png)
```mermaid
flowchart LR
subgraph 模型側
T1[browser]
T2[computer_observe / computer_act]
T3[shell / files]
end
subgraph 容器內
CDP[Chromium CDP]
ATSPI[AT-SPI]
XD[xdotool / Xvfb]
VNC[x11vnc]
end
T1 --> CDP
T2 --> ATSPI
T2 --> XD
T3 --> XD
U[你的瀏覽器] --> VNC
```
![能認控制項,就不要猜座標](./docs/diagrams/click.png)
**三層找得到什麼、點得了什麼**
模型從不直接連 VNC。它只打 API 工具;工具經 sandbox HTTP 進 supervisor`docker exec` 或打容器內 `controld`
1. **CDP網頁**
Chromium 開著時,`browser` 拿 DOM可點的控制項編成 1…N截圖上蓋黃字。`click {element:N}` 會捲到視窗外的節點。這是訓練系統、信箱、後台的主路徑。`computer_act` 點在瀏覽器視窗上會被拒,避免用像素點網頁。
編號只畫在給模型的 JPEG 上。VNC 是乾淨桌面。每次 navigationsnapshot 會重編號,舊 id 作廢。`computer_act` 點在瀏覽器視窗上會被拒,避免用像素點網頁。解析度契約是 **1280×800**
2. **AT-SPI原生 GUI**
沒有 DOM 時檔案選取、XFCE 對話框),`computer_observe` 走無障礙樹。編號是控制項,不是視窗外框。
每個 bot 一個 `computer_screens`slot、DISPLAY、執行租約、控制租約。人接管寫 `control_holder=user`worker 在回合邊界停,不跟你搶滑鼠。`view_only` 用 postMessage 切,不重掛 iframe所以換手時預覽不會黑掉。
3. **座標(最後)**
畫布、無樹的 widget 才用 `computer_act` 的 x,y。解析度契約是 **1280×800**
**編號從哪來**
`overlay_elements` 只畫在給模型的 JPEG 上。VNC 是乾淨桌面。每次 navigationsnapshot 會重編號,舊 id 作廢。
**誰可以動滑鼠**
每個 bot 一個 `computer_screens`slot、DISPLAY、執行租約 `execution_run_id`、控制租約 `control_holder`。run 要 GUI 時 `take_screen_execution` 把 fence +1較新的 fence 贏。人接管寫 `control_holder=user`worker 在回合邊界停,不跟你搶滑鼠。
**你看到的畫面**
`screen_proxy` 只接受已登入的 GETWebSocket。頁面本體是 `apps/web/vnc.html`。`view_only` 用 postMessage 切,不重掛 iframe所以換手時預覽不會黑掉。
**容器內 controld**
`lazyboy-controld``127.0.0.1:7070`,要 `LAZYBOY_CONTROL_TOKEN`。supervisor 的觀察/動作能打通就走它,否則退回 `import``xdotool` 指令。
**Team 多螢幕**
slot 0 = `:1` / VNC 5900 / 畫面 6080。slot N = `:N+1` / 5900+N / 6080+N。`lazyboy-screen ensure` 在同一個容器裡再長一組 Xvfb。瀏覽器設定檔預設 per-bot兩個 bot 搶同一份 shared profile 會拿到「profile locked」檔案與 shell 仍可用。
`lazyboy-controld``127.0.0.1:7070`。Team 多螢幕slot 0 = `:1`slot N = `:N+1`。`lazyboy-screen ensure` 在同一個容器裡再長一組 Xvfb。
---
## 教技能怎麼做
## 模型金鑰從哪來
```mermaid
sequenceDiagram
participant H as 你
participant A as API
participant C as 桌面
participant M as 模型
![由近到遠,找到第一把就用](./docs/diagrams/keys.png)
H->>A: POST /skills/start {goal}
A->>C: 開電腦、把控制權給人
A->>C: 啟動 CDP recorder
loop 約 1.5s
A->>C: 視窗標題 + 粗略畫面簽名
Note over A: 簽名沒變就不存,避免閒置把 60 幀用完
end
H->>C: 正常操作(密碼欄不錄)
H->>A: POST /skills/stop
A->>C: 停 recorder
A->>M: 目標 + 語意事件 + 最多 8 張關鍵畫面
M-->>A: playbook意圖、輸入、步驟、怎麼驗收
A-->>H: 草稿,可改名、試跑、匯出 JSON
```
`crates/harness` 不管滑鼠,只決定這次 run 要用哪一家模型。真正的 agent 迴圈在 `crates/api/src/runs.rs`
金鑰:**這個機器人 → 工作區設定 → 環境變數**。API 跑在 Docker 時,迴圈位址 `127.0.0.1` 會被改成 `host.docker.internal`,才能打到你本機的相容端點。
---
## 教會它
![你做一次,它記住為什麼](./docs/diagrams/teach.png)
之後 run 若 prompt 對得上技能名,會把完整 playbook 塞進當則,並清掉舊聊天以免模型複誦上次的「還在倒數」。執行仍用 `browser``computer_act`,在**現在**的畫面上找「Next」不是記像素。
---
## 排程怎麼進 run
## 排程怎麼進工作
```mermaid
flowchart LR
A[對話 create_schedule 或側欄新增] --> B[(schedules 表)]
B --> C[tick_loop]
C -->|next_run_at 到了| D[INSERT runs queued]
D --> E[同一個 worker_loop]
E --> F[普通 execute_runprompt 是 instructions]
```
![到點以後,跟你傳訊息同一條路](./docs/diagrams/schedule.png)
Cron 五欄。時區寫在列上,預設台北。`run now` 只是立刻插一筆 run不改下一拍時間。
Cron 五欄。時區寫在列上,預設台北。`立刻跑` 只是立刻插一筆 run不改下一拍時間。
---
## 專案目錄(二次開發從這裡找)
Cargo workspace。契約在 `contracts`,畫面邏輯在 `control`HTTP 與 agent 迴圈在 `api`Docker 生命週期在 `supervisor`。前端是獨立的 Vite app由 API 把 `apps/web/dist`(或開發時的 `apps/web`)端出去。
![把它當成幾間房間,不是分層蛋糕](./docs/diagrams/folders.png)
Cargo workspace。畫面在 `apps/web`,對話與工作在 `api`,怎麼點在 `control`,開機在 `supervisor` + `image/computer`,問哪一家模型在 `harness`。前端是獨立的 Vite app由 API 把 `apps/web/dist`(或開發時的 `apps/web`)端出去。
```text
LazyBoy/
@ -449,17 +264,6 @@ LazyBoy/
└── reference/rakazo/ 上游參考實作,不要當 runtime 依賴
```
crate 依賴方向(不要倒過來 import
```text
api → harness, control, sandbox, contracts
sandbox → control, contracts
supervisor → control, contracts
controld → control, contracts
harness → contracts
control → contracts
```
### 想改什麼,開哪個檔
| 你要做的事 | 先開 |

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,10 @@
"blobatar": "^2.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-useanimations": "^2.10.0"
"react-markdown": "^10.1.0",
"react-useanimations": "^2.10.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^19.0.0",

View File

@ -18,6 +18,7 @@ import { api, ApiError } from "./api";
import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar";
import { t, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
import { ChatMarkdown, CopyMessageButton } from "./markdown";
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, type CronPreset, type ScheduleItem } from "./schedule";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
@ -297,7 +298,7 @@ export function App(){
<main className="chat-panel">
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} size={32} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>)}{!hideBody&&<span className="message-body">{message.body}</span>}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>)}{!hideBody&&(message.role==="assistant"?<div className="message-stack"><div className="message-body md"><ChatMarkdown>{message.body}</ChatMarkdown></div>{message.body.trim()?<CopyMessageButton text={message.body}/>:null}</div>:<span className="message-body">{message.body}</span>)}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
<div className={`composer-dock ${statusMembers.length?"has-status":""}`}>
@ -411,16 +412,20 @@ function VaultPane({bot}:{bot:Bot}){
const load=useCallback(()=>api<Account[]>(`/api/bots/${bot.id}/accounts`).then(setItems),[bot.id]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
return <div className="memory-pane">
function setField<K extends keyof typeof form>(key:K,value:string){setForm(current=>({...current,[key]:value}))}
return <div className="vault-pane">
<p className="memory-help">{t("accountsHelp")}</p>
<form onSubmit={e=>{e.preventDefault();if(!form.site.trim()||!form.username.trim()||!form.password)return;void run(async()=>{await api(`/api/bots/${bot.id}/accounts`,{method:"POST",body:JSON.stringify(form)});setForm({site:"",host:"",username:"",password:"",notes:""})})}}>
<label>{t("accountSite")}<input value={form.site} onChange={e=>setForm({...form,site:e.target.value})} placeholder={t("accountSitePlaceholder")}/></label>
<label>{t("accountHost")}<input value={form.host} onChange={e=>setForm({...form,host:e.target.value})} placeholder={t("accountHostPlaceholder")}/></label>
<label>{t("accountUsername")}<input value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/></label>
<label>{t("accountPassword")}<input type="password" value={form.password} onChange={e=>setForm({...form,password:e.target.value})} autoComplete="new-password"/></label>
<form className="vault-form" onSubmit={e=>{e.preventDefault();if(!form.site.trim()||!form.username.trim()||!form.password)return;void run(async()=>{await api(`/api/bots/${bot.id}/accounts`,{method:"POST",body:JSON.stringify(form)});setForm({site:"",host:"",username:"",password:"",notes:""})})}}>
<div className="vault-fields">
<label>{t("accountSite")}<input value={form.site} onChange={e=>setField("site",e.target.value)} placeholder={t("accountSitePlaceholder")} autoComplete="off"/></label>
<label>{t("accountHost")}<input value={form.host} onChange={e=>setField("host",e.target.value)} placeholder={t("accountHostPlaceholder")} autoComplete="off"/></label>
<label>{t("accountUsername")}<input value={form.username} onChange={e=>setField("username",e.target.value)} autoComplete="username"/></label>
<label>{t("accountPassword")}<input type="password" value={form.password} onChange={e=>setField("password",e.target.value)} autoComplete="new-password"/></label>
</div>
<label>{t("accountNotes")}<input value={form.notes} onChange={e=>setField("notes",e.target.value)} placeholder={t("accountNotesPlaceholder")} autoComplete="off"/></label>
<button className="primary" disabled={busy||!form.site.trim()||!form.username.trim()||!form.password}>{t("addAccount")}</button>
</form>
<div className="account-list">{items.length===0?<p>{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small><button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}>{t("delete")}</button></div>)}</div>
<div className="account-list">{items.length===0?<p className="vault-empty">{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><div className="account-row-main"><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small>{item.notes?<small className="account-notes">{item.notes}</small>:null}</div><button type="button" className="icon-button" title={t("delete")} aria-label={t("delete")} disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button></div>)}</div>
{error&&<div className="pane-error">{error}</div>}
</div>
}

View File

@ -1,9 +1,16 @@
.messages{flex:1;overflow:auto;padding:34px max(34px,7vw) 150px;display:flex;flex-direction:column;gap:18px}
.message{display:flex}
.message>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
.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}
.message.user{justify-content:flex-end}
.message.user>.message-body{background:var(--cream);color:#1a1a1a}
.message.assistant>.message-body{background:#19191c}
.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%}
.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)}
.copy-msg.copied,.copy-msg.copied:hover{color:var(--accent);background:transparent;cursor:default}
.composer{position:absolute;left:max(34px,5vw);right:max(34px,5vw);bottom:22px;display:flex;align-items:flex-end;gap:8px;border:1px solid var(--border);background:#121214;border-radius:26px;padding:8px;box-shadow:0 16px 50px rgba(0,0,0,.22)}
.composer textarea{flex:1;min-height:42px;max-height:140px;resize:none;border:0;outline:0;background:transparent;color:var(--ink);padding:11px 4px}
.composer-plus{background:transparent;color:var(--muted)}
@ -35,7 +42,7 @@
.file-card-remove svg{width:14px;height:14px}
.message.with-files{align-items:flex-end;flex-direction:column;gap:8px}
.message.with-files .msg-attachments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:min(420px,82%)}
.message.with-files .message-body{max-width:82%}
.message.with-files .message-body,.message.with-files .message-stack{max-width:82%}
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
.stop-send{background:#f2f2f2;color:#151515}
.stop-send svg{width:14px;height:14px;fill:currentColor}
@ -55,5 +62,6 @@
.message.assistant.spoken{column-gap:11px;row-gap:6px}
.message.assistant.spoken>.msg-avatar{max-width:none;padding:0;border-radius:0;background:transparent}
.message.assistant.spoken .speaker{padding:0 2px;letter-spacing:.01em}
.message.assistant.spoken>.message-body{grid-column:2;max-width:82%;line-height:1.52;border-radius:6px 18px 18px 18px}
.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}
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}

View File

@ -13,6 +13,7 @@ export const zhTW = {
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。",
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
@ -99,7 +100,8 @@ export const zhTW = {
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
back: "返回", accounts: "帳號", accountsHelp: "存在這台機器人自己的保險箱。密碼只在這裡填,不會進聊天、也不會給模型看。排程碰到登入牆時會自動填表。",
accountSite: "站名", accountSitePlaceholder: "例如Gmail", accountHost: "網址或網域", accountHostPlaceholder: "mail.google.com",
accountUsername: "帳號", accountPassword: "密碼", accountPasswordKeep: "留空表示不改密碼", accountNotes: "備註",
accountUsername: "帳號", accountPassword: "密碼", accountPasswordKeep: "留空表示不改密碼",
accountNotes: "備註", accountNotesPlaceholder: "選填,例如:公司信箱",
addAccount: "新增帳號", noAccounts: "還沒有已存帳號。排程若要自動登入,先在這裡加一筆。",
loginNeedsYou: "需要你在鍵盤上登入", loginOpenScreen: "打開它的畫面", loginWhy: "登入之後:{why}",
scheduleChip: "已排程", scheduleRunChip: "排程執行",

56
apps/web/src/markdown.css Normal file
View File

@ -0,0 +1,56 @@
.md-body{min-width:0;overflow-wrap:anywhere}
.md-body>:first-child,.md-body>:first-child>:first-child{margin-top:0}
.md-body>:last-child,.md-body>:last-child>:last-child{margin-bottom:0}
.md-body p,.md-body blockquote,.md-body ul,.md-body ol,.md-body table{margin:.65em 0}
.md-body h1,.md-body h2,.md-body h3,.md-body h4,.md-body h5,.md-body h6{
margin:.85em 0 .3em;color:var(--ink);font-weight:400;line-height:1.3;letter-spacing:.01em
}
.md-body h1{font-size:1.22em}
.md-body h2{font-size:1.12em}
.md-body h3,.md-body h4,.md-body h5,.md-body h6{font-size:1.04em}
.md-body ul,.md-body ol{padding-left:1.4em}
.md-body ul{list-style:disc}
.md-body ol{list-style:decimal}
.md-body li+li{margin-top:.28em}
.md-body li>p{margin:0}
.md-body .contains-task-list,.md-body .task-list-item{list-style:none}
.md-body .contains-task-list{padding-left:0}
.md-body a{
color:var(--accent);text-decoration:underline;
text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);
text-underline-offset:.16em
}
.md-body a:hover{color:#7ee0c8}
.md-body code{
border:1px solid var(--border);border-radius:.35em;background:#101012;
padding:.08em .32em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:.88em;direction:ltr;unicode-bidi:isolate
}
.md-body pre{
max-width:100%;overflow-x:auto;margin:0;border:1px solid var(--border);
border-radius:12px;background:#0c0c0d;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-copy{
position:absolute;top:.45em;right:.45em;display:grid;place-items:center;
width:28px;height:28px;border:1px solid var(--border);border-radius:8px;
background:#17171a;color:var(--muted);opacity:0;cursor:pointer
}
.md-pre-wrap:hover .md-copy,.md-copy:focus-visible{opacity:1}
.md-copy:hover{color:var(--ink);background:#202024}
.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 th,.md-body td{
border:1px solid var(--border);padding:.4em .6em;text-align:left;overflow-wrap:break-word
}
.md-body th{background:#1a1a1d;color:var(--ink);font-weight:400}
.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}
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:#1a1a1a}
.user .md-body a{color:#0f766e}
.user .md-copy{background:#ececea;color:#555}

110
apps/web/src/markdown.tsx Normal file
View File

@ -0,0 +1,110 @@
import { memo, useCallback, useRef, useState, type ComponentPropsWithoutRef } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { t } from "./i18n";
import "./markdown.css";
const protocolPattern = /^([a-z][a-z\d+.-]*):/i;
const safeProtocols = new Set(["http", "https", "mailto", "tel"]);
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;
return undefined;
}
function CopyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12.5 9.5 18 20 6" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function useCopiedFlag() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number>(undefined);
const markCopied = useCallback(() => {
setCopied(true);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
}, []);
return {copied, markCopied};
}
function copyText(text: string): Promise<boolean> {
if (!navigator.clipboard) return Promise.resolve(false);
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
}
function CodeBlock(props: ComponentPropsWithoutRef<"pre">) {
const preRef = useRef<HTMLPreElement>(null);
const {copied, markCopied} = useCopiedFlag();
const handleCopy = useCallback(() => {
void copyText(preRef.current?.textContent ?? "").then((ok) => { if (ok) markCopied(); });
}, [markCopied]);
return (
<div className="md-pre-wrap">
<pre {...props} ref={preRef}/>
<button type="button" className="md-copy" onClick={handleCopy} aria-label={copied ? t("copiedCode") : t("copyCode")}>
{copied ? <CheckIcon/> : <CopyIcon/>}
</button>
</div>
);
}
const components: Components = {
a({node: _node, ...props}) {
return <a {...props} target="_blank" rel="noreferrer noopener"/>;
},
img({node: _node, ...props}) {
return <img {...props} alt={props.alt ?? ""} loading="lazy"/>;
},
pre({node: _node, ...props}) {
return <CodeBlock {...props}/>;
},
};
export function CopyMessageButton({text}:{text:string}) {
const {copied, markCopied} = useCopiedFlag();
if (!text.trim()) return null;
return (
<button
type="button"
className={`copy-msg ${copied ? "copied" : ""}`}
title={copied ? t("copiedMessage") : t("copyMessage")}
aria-label={copied ? t("copiedMessage") : t("copyMessage")}
onClick={() => { void copyText(text).then((ok) => { if (ok) markCopied(); }); }}
>
{copied ? <CheckIcon/> : <CopyIcon/>}
<span>{copied ? t("copiedMessage") : t("copyMessage")}</span>
</button>
);
}
export const ChatMarkdown = memo(function ChatMarkdown({children}:{children:string}) {
return (
<div className="md-body">
<ReactMarkdown
components={components}
remarkPlugins={[remarkGfm, remarkBreaks]}
skipHtml
urlTransform={(url) => sanitizeMarkdownUrl(url) ?? ""}
>
{children}
</ReactMarkdown>
</div>
);
});

View File

@ -545,13 +545,13 @@
.side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:14px 16px 16px;overflow:hidden}
.pane-form,.memory-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px}
.pane-form,.memory-pane,.vault-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px;align-content:start}
.pane-form label,.memory-pane label,.mcp-add label{display:grid;gap:8px;color:var(--muted);font-size:13px}
.pane-form label,.memory-pane label,.mcp-add label,.vault-pane label{display:grid;gap:8px;color:var(--muted);font-size:13px;min-width:0}
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input{height:40px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input,.vault-pane input{width:100%;min-width:0;height:40px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit;outline:none}
@ -860,16 +860,35 @@
.dialog.skill-edit .dialog-actions .outline svg{fill:none;stroke:currentColor;width:14px;height:14px}
.account-list{display:grid;gap:10px}
.vault-pane{flex:1;container-type:inline-size}
.vault-form{display:grid;gap:12px}
.account-row{display:grid;gap:4px;padding:10px;border:1px solid var(--border);border-radius:12px}
.vault-fields{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px}
@container (max-width:310px){
.vault-fields{grid-template-columns:1fr}
}
.account-row strong{font-size:14px}
.vault-form>.primary{justify-self:start}
.account-list{display:grid;gap:8px;margin-top:2px;padding-top:16px;border-top:1px solid var(--line)}
.account-row small{color:var(--muted)}
.vault-empty{margin:0;color:var(--faint);font-size:13px;line-height:1.5}
.account-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0;padding:10px 6px 10px 12px;border:1px solid var(--border);border-radius:12px;background:#101012}
.account-row-main{display:grid;gap:3px;min-width:0}
.account-row-main strong{overflow:hidden;font-size:14px;font-weight:400;text-overflow:ellipsis;white-space:nowrap}
.account-row-main small{overflow:hidden;color:var(--muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}
.account-row-main .account-notes{white-space:normal;overflow-wrap:anywhere}
.account-row .icon-button{color:var(--muted)}
.account-row .icon-button:hover:not(:disabled){color:var(--danger)}
@keyframes monitor-breathe{50%{transform:translateY(-4px);box-shadow:0 0 28px #65dcb325}}

View File

@ -2,7 +2,7 @@
@media(max-width:700px){.app-shell{display:block}.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}.sidebar.open{transform:none}.chat-panel{height:100%}.mobile-menu{display:inline-flex}.topbar{padding:0 12px}.messages{padding:24px 18px 130px}.composer{left:12px;right:12px}.computer-overlay>header{height:auto;min-height:64px;flex-wrap:wrap;padding:10px}.computer-overlay>header>div:last-child{flex-wrap:wrap;justify-content:flex-end}.overlay-screen{padding:8px}.mode-grid{grid-template-columns:1fr}}
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@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%}.message>span{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}.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:118px}.chat-panel:has(.composer-dock.has-status) .messages{padding-bottom:178px}}
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{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}.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:118px}.chat-panel:has(.composer-dock.has-status) .messages{padding-bottom:178px}}
@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}}
@media(max-width:1050px){

View File

@ -0,0 +1,30 @@
# Bug 檢查與改善計劃
檢查日期2026-09-05。範圍為目前工作目錄的前端、排程與 VNC 剪貼簿整合;不是全專案完整稽核。保留原有未提交修改。
## 本次已修正
- 頭像:縮小聊天氣泡 span 選擇器範圍,固定頭像尺寸,群聊頂欄改以實際 32px 計算堆疊位置,移除額外縮放。
- 綠點:恢復頭像本身的 presence取消側欄額外偽元素限制綠點大小未讀仍以右上藍點區別。
- 排程補齊表單、checkbox、換行及按鈕配置由整個電腦側欄負責捲動避免列表與編輯器壓縮預覽。聊天排程卡片與文字採上下排列。
- 等待動畫:電腦採獨立薄荷色機器人、呼吸光環與琥珀軌道;聊天只依工作中的對話顯示思考,不再因電腦啟動/連線而出現。排除 null session ID 互相比較產生假忙碌。
- 剪貼簿:在 noVNC 接收前攔截 Ctrl/Cmd+V讀取本機文字後只送出一次遠端貼上權限不足時開啟手動貼上框。延遲貼上期間換連線或變唯讀就取消。父子訊息驗證來源視窗避免舊 iframe 更新目前狀態。
## 計劃項目完成狀態
| 優先 | 項目 | 實作與驗證 |
| --- | --- | --- |
| P1 | 固定間隔排程 | 改用 `@every Nm/Nh/Nd`,一天固定 24 小時跨月、DST、漏跑相位測試通過。舊 cron 保留原文 |
| P1 | Cron 編輯不失真 | 僅五欄及合法範圍轉換預設;未知格式保留 Advanced空值禁止儲存修正 UnixRust 星期編號差異 |
| P1 | 電腦啟動失敗恢復 | 失敗立即刷新狀態,刷新失敗回復前值;回應只更新原本的 bot |
| P1 | 遠端貼上同步 | 後端確認 X11 剪貼簿內容後才貼上;終端使用 Ctrl+Shift+V連續貼上依序處理 |
| P2 | 複製與權限提示 | 顯示同步結果、權限失敗退路macOS Cmd+C 交由後端判斷終端快捷鍵 |
| P2 | CSS 拆分 | Avatar、Chat、Computer、Schedule 分檔,響應式規則集中最後載入 |
| P2 | 主 JS 過大 | 動畫改為 lazy import主檔約 446 kB動畫 chunk 約 318 kB消除 500 kB chunk 警告 |
## 驗證與限制
- 前端 TypeScript、正式打包、5 個 Node 回歸測試、3 個 Python 控制測試通過Rust 驗證詳見[安全與 Harness 檢查](security-and-harness-review.md)。
- 瀏覽器工具回報沒有可用瀏覽器,尚未完成視覺截圖與真實 VNC 端到端驗收。這些項目已完成程式修正,不能等同所有畫面已實機驗收。
- 待實機驗收1440×900、1280×720、390×844群聊、長排程、多筆排程、開機與聊天各自獨立、權限拒絕、中文emoji多行與重連。
- 剪貼簿支援純文字圖片、檔案不在此次實作範圍。Lottie 上游 eval 提醒仍存在;未量測首屏時間。

925
docs/diagrams.html Normal file
View File

@ -0,0 +1,925 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<title>LazyBoy diagrams</title>
<style>
@font-face {
font-family: Huninn;
src: url("../apps/web/node_modules/@fontsource/huninn/files/huninn-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F;
}
@font-face {
font-family: Huninn;
src: url("../apps/web/node_modules/@fontsource/huninn/files/huninn-chinese-traditional-400-normal.woff2") format("woff2");
unicode-range: U+4E00-9FFF, U+3400-4DBF, U+F900-FAFF, U+3000-303F, U+FF00-FFEF;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 1280px;
height: 720px;
overflow: hidden;
background: #f3f3f5;
color: #161618;
font-family: Huninn, "PingFang TC", sans-serif;
}
.slide {
width: 1280px;
height: 720px;
padding: 32px 48px 30px;
display: none;
flex-direction: column;
}
.slide.on { display: flex; }
.kicker {
font-size: 12px;
letter-spacing: .16em;
color: #0f766e;
margin-bottom: 6px;
}
h1 {
margin: 0;
font-size: 30px;
font-weight: 400;
letter-spacing: .01em;
}
.sub {
margin: 6px 0 20px;
color: #6b6b73;
font-size: 14px;
line-height: 1.5;
}
.row { display: flex; gap: 16px; align-items: stretch; flex: 1; min-height: 0; }
.col3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; flex: 1; min-height: 0; }
.col4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 14px; flex: 1; min-height: 0; }
.col5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; flex: 1; min-height: 0; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; flex: 1; min-height: 0; }
.rooms {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 14px;
flex: 1;
min-height: 0;
}
.card {
background: #fff;
border-radius: 22px;
border: 1px solid #ececef;
box-shadow: 0 16px 40px rgba(20,20,24,.06);
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.card.dark { background: #111113; color: #f1f1f2; border-color: #1c1c20; }
.viz {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
.copy { padding: 14px 16px 16px; }
.copy h2 { margin: 0 0 4px; font-size: 17px; font-weight: 400; }
.copy p { margin: 0; color: #6b6b73; font-size: 13px; line-height: 1.45; }
.dark .copy p { color: #9a9aa2; }
.n {
width: 24px; height: 24px; border-radius: 50%;
display: grid; place-items: center;
background: #3ec5a8; color: #083f34; font-size: 12px;
margin-bottom: 8px;
}
.pill {
display: inline-flex; align-items: center;
height: 22px; padding: 0 9px; border-radius: 999px;
background: #eef8f5; color: #0f766e; font-size: 11px;
}
.dark .pill { background: #1a2e2a; color: #7ee0c8; }
.face { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.blob { width: 36px; height: 36px; flex: 0 0 36px; }
.chev {
width: 22px; flex: 0 0 22px;
display: grid; place-items: center;
color: #3ec5a8; font-size: 22px;
align-self: center;
}
/* mini chat */
.chat-mini {
position: absolute; inset: 16px 14px 12px;
display: flex; flex-direction: column; gap: 8px;
justify-content: flex-end;
}
.b {
max-width: 86%;
padding: 8px 12px;
border-radius: 14px;
font-size: 12px;
line-height: 1.4;
}
.me { align-self: flex-end; background: #f1f1ef; color: #1a1a1a; }
.them { align-self: flex-start; background: #19191c; color: #eee; }
.think-row {
display: flex; align-items: center; gap: 8px;
color: #85858a; font-size: 11px;
}
/* xfce-ish desktop */
.desk {
position: absolute; inset: 0;
background:
radial-gradient(circle at 18% 0%, #1a2430 0%, transparent 46%),
linear-gradient(180deg, #15202b, #0d1218);
}
.desk.dim { filter: saturate(.4) brightness(.55); }
.desk.off { background: #09090b; }
.win {
position: absolute;
left: 16px; top: 14px; right: 16px; bottom: 26px;
border-radius: 8px;
background: #fff;
overflow: hidden;
box-shadow: 0 10px 24px rgba(0,0,0,.32);
display: grid;
grid-template-rows: 22px 26px 1fr;
}
.win-bar {
background: #e8e8ea;
display: flex; align-items: center; gap: 5px;
padding: 0 8px;
color: #666; font-size: 10px;
}
.win-bar i { width: 7px; height: 7px; border-radius: 50%; background: #c4c4c8; font-style: normal; }
.addr {
background: #f4f4f6;
display: flex; align-items: center;
padding: 0 10px;
color: #666; font-size: 10px;
border-bottom: 1px solid #ececf0;
}
.page-body {
padding: 12px 14px 14px;
color: #222;
display: flex;
flex-direction: column;
min-height: 0;
}
.page-body h3 { margin: 0 0 4px; font-size: 14px; font-weight: 400; }
.page-body p { margin: 0; color: #666; font-size: 11px; line-height: 1.4; }
.video {
flex: 1;
min-height: 40px;
margin: 10px 0 8px;
border-radius: 8px;
background:
linear-gradient(160deg, #1c2838 0%, #1a2c2a 55%, #2a8f7a 140%);
}
.lessons { margin: 8px 0 0; padding: 0; list-style: none; font-size: 11px; color: #555; }
.lessons li {
display: flex; justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #f0f0f2;
}
.lessons li.on { color: #0f766e; }
.mail { margin-top: 8px; font-size: 11px; color: #444; }
.mail .m { padding: 7px 8px; border-radius: 8px; }
.mail .m.on { background: #eef8f5; }
.progress {
margin-top: 8px; height: 6px; border-radius: 99px; background: #ececf0; overflow: hidden;
}
.progress b { display: block; width: 62%; height: 100%; background: #3ec5a8; }
.next {
margin-top: 10px; height: 26px; width: 68px;
border-radius: 6px; background: #1a1a1c; color: #fff;
display: grid; place-items: center; font-size: 11px;
position: relative;
flex: 0 0 auto;
}
.btns { display: flex; gap: 8px; margin-top: auto; padding-top: 10px; }
.tag {
position: absolute;
right: -9px; top: -9px;
width: 20px; height: 20px; border-radius: 5px;
background: #ffe66d; color: #111;
display: grid; place-items: center; font-size: 11px;
box-shadow: 0 2px 6px rgba(0,0,0,.18);
}
.panelbar {
position: absolute; left: 0; right: 0; bottom: 0; height: 22px;
background: #1b242e; border-top: 1px solid #2a3540;
}
.zzz {
position: absolute; right: 18px; top: 14px;
color: #cdeae3; font-size: 22px; letter-spacing: 2px;
}
.house {
position: absolute; inset: 0;
display: grid; place-items: center;
color: #3ec5a8; font-size: 13px; text-align: center; line-height: 1.5;
}
/* queue / chips */
.stack {
position: absolute; inset: 18px 16px;
display: flex; flex-direction: column; gap: 8px; justify-content: center;
}
.job {
height: 36px; border-radius: 10px;
display: flex; align-items: center; padding: 0 12px;
font-size: 12px; gap: 8px;
}
.job.on { background: #111113; color: #fff; }
.job.wait { background: #f1f1ef; color: #666; }
.job .dot { width: 7px; height: 7px; border-radius: 50%; background: #3ec5a8; }
.fork {
position: absolute; inset: 16px 12px;
display: flex; flex-direction: column; gap: 10px; justify-content: center;
}
.lane {
border-radius: 12px; padding: 10px 12px;
font-size: 12px; line-height: 1.4;
}
.lane.a { background: #eef8f5; color: #0f766e; }
.lane.b { background: #111113; color: #f1f1f2; }
.keys-viz {
position: absolute; inset: 0;
display: grid; place-items: center;
}
.key {
width: 88px; height: 88px;
}
.clock {
width: 120px; height: 120px;
}
.playbook {
position: absolute; inset: 18px 16px;
background: #fff; border-radius: 10px;
padding: 12px; box-shadow: 0 8px 20px rgba(0,0,0,.08);
font-size: 11px; color: #333; line-height: 1.55;
}
.playbook b { display: block; font-weight: 400; font-size: 13px; margin-bottom: 6px; }
.events {
position: absolute; inset: 16px 14px;
display: flex; flex-direction: column; gap: 7px; justify-content: center;
}
.ev {
background: #fff; border-radius: 10px; padding: 8px 10px;
font-size: 11px; color: #333;
box-shadow: 0 4px 12px rgba(0,0,0,.06);
}
.ev em { color: #0f766e; font-style: normal; }
.dialog {
position: absolute; left: 18px; right: 18px; top: 28px; bottom: 28px;
background: #f6f6f4; border-radius: 10px;
box-shadow: 0 12px 28px rgba(0,0,0,.2);
padding: 12px;
color: #222; font-size: 12px;
}
.dialog h4 { margin: 0 0 10px; font-weight: 400; font-size: 13px; }
.file { padding: 6px 8px; border-radius: 6px; }
.file.on { background: #d7efe8; }
.dlg-btn {
position: absolute; right: 12px; bottom: 12px;
height: 26px; padding: 0 12px; border-radius: 6px;
background: #1a1a1c; color: #fff; display: grid; place-items: center; font-size: 11px;
}
.canvas {
position: absolute; inset: 16px;
background:
linear-gradient(#ececf0 1px, transparent 1px) 0 0 / 24px 24px,
linear-gradient(90deg, #ececf0 1px, transparent 1px) 0 0 / 24px 24px,
#fafafa;
border-radius: 10px;
}
.cross {
position: absolute; left: 52%; top: 46%;
width: 18px; height: 18px;
transform: translate(-50%,-50%);
}
.cross:before, .cross:after {
content: ""; position: absolute; background: #d23b3b;
}
.cross:before { left: 8px; top: 0; width: 2px; height: 18px; }
.cross:after { left: 0; top: 8px; width: 18px; height: 2px; }
.xy {
position: absolute; left: 54%; top: 52%;
font-size: 11px; color: #d23b3b;
}
.alarm {
position: absolute; inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.cron {
margin-top: 8px; font-size: 12px; color: #0f766e;
background: #eef8f5; padding: 4px 10px; border-radius: 999px;
}
.room {
border-radius: 20px;
padding: 18px 18px 16px;
color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 0;
}
.room small { opacity: .82; font-size: 13px; line-height: 1.45; }
.room h2 { margin: 0 0 4px; font-size: 20px; font-weight: 400; }
.room .path { font-size: 11px; opacity: .7; letter-spacing: .02em; }
.tiny-ui {
margin: 14px 0 10px;
background: #1a1a1d;
border-radius: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-columns: 54px 1fr 78px;
}
.tiny-ui > div { border-right: 1px solid #2a2a2e; }
.tiny-ui > div:last-child { border: 0; background: #121214; }
.form {
position: absolute; inset: 22px 18px;
background: #fff;
border-radius: 12px;
padding: 18px;
box-shadow: 0 8px 20px rgba(0,0,0,.06);
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.form.dark {
background: #19191c;
color: #eee;
}
.form label { display: block; font-size: 11px; color: #888; margin-bottom: 8px; }
.field {
height: 36px; border-radius: 10px;
display: flex; align-items: center; padding: 0 12px;
font-size: 13px; gap: 8px;
background: #f4f4f6;
}
.form.dark .field { background: #111113; }
.ok { margin-left: auto; color: #3ec5a8; font-size: 12px; }
.env-lines {
font-size: 12px; line-height: 1.8; color: #444;
font-family: ui-monospace, Menlo, monospace;
}
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip {
background: #f1f1ef; border-radius: 999px;
padding: 4px 10px; font-size: 11px; color: #333;
}
.dark .chip { background: #1c1c20; color: #ddd; }
</style>
</head>
<body>
<section class="slide" id="map">
<div class="kicker">LAZYBOY · 怎麼轉起來</div>
<h1>其實只有三個角色</h1>
<p class="sub">不是一疊伺服器名詞。你說話、中間有人幫忙想、右邊那台電腦去按。</p>
<div class="row">
<article class="card">
<div class="viz" style="background:#0d0d0e">
<div class="chat-mini">
<div class="b me">把這堂課看完</div>
<div class="b them">好,我去開電腦點 Next。</div>
<div class="think-row">右側還能看見它的桌面</div>
</div>
</div>
<div class="copy">
<div class="face">
<svg class="blob" viewBox="0 0 64 64"><circle cx="32" cy="32" r="28" fill="#f1f1ef"/><circle cx="32" cy="26" r="8" fill="#c8c8cc"/><path d="M18 46c3-8 9-12 14-12s11 4 14 12" fill="#c8c8cc"/></svg>
<div><h2></h2><span class="pill">瀏覽器</span></div>
</div>
<p>聊天、看桌面、必要時接手滑鼠。分頁開著,電腦就不會睡著。</p>
</div>
</article>
<div class="chev"></div>
<article class="card dark">
<div class="viz" style="background:#0a0a0c;display:grid;place-items:center">
<div style="text-align:center">
<svg width="72" height="72" viewBox="0 0 64 64"><path fill="#3ec5a8" d="M33 5c11.5 1 24 11 23 26-1 14-11 27-25 28S6 47 7 31 21 4 33 5z"/><ellipse cx="25.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><ellipse cx="40.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><circle cx="26.8" cy="27.2" r="1.3" fill="#f3f3f6"/><circle cx="41.8" cy="27.2" r="1.3" fill="#f3f3f6"/></svg>
<div class="chips" style="justify-content:center">
<span class="chip">記憶</span><span class="chip">技能</span><span class="chip">要不要開機</span>
</div>
</div>
</div>
<div class="copy">
<div class="face">
<div><h2>LazyBoy</h2><span class="pill">想、記、叫人做事</span></div>
</div>
<p>把話留給模型、把檔案留給磁碟。問候不會無故開機。</p>
</div>
</article>
<div class="chev"></div>
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>單元 1218</p>
<div class="video"></div>
<div class="progress"><b></b></div>
<div class="next">Next</div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="face">
<svg class="blob" viewBox="0 0 64 64"><rect x="8" y="12" width="48" height="36" rx="6" fill="#111113"/><rect x="14" y="18" width="36" height="20" fill="#3ec5a8" opacity=".35"/><rect x="20" y="50" width="24" height="4" rx="2" fill="#ccc"/></svg>
<div><h2>它的電腦</h2><span class="pill">真的 Linux 桌面</span></div>
</div>
<p>Debian、視窗、瀏覽器。家目錄在你硬碟上不在別人雲裡。</p>
</div>
</article>
</div>
</section>
<section class="slide" id="chat">
<div class="kicker">傳一句話之後</div>
<h1>它先決定:聊就好,還是要動手</h1>
<p class="sub">同一條路走完。差別只在第四步要不要打開那台電腦。</p>
<div class="col5">
<article class="card">
<div class="viz" style="background:#0d0d0e">
<div class="chat-mini">
<div class="b me">哈囉</div>
<div class="b me" style="opacity:.45">把課看完</div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>你送出</h2><p>訊息進這則對話。同一句話重送不會變兩則。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="stack">
<div class="job on"><span class="dot"></span>現在這則</div>
<div class="job wait">下一則等著</div>
<div class="job wait">再下一則</div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>排隊</h2><p>這個 Agent 一次只做一件事。忙完才輪到下一則。</p></div>
</article>
<article class="card dark">
<div class="viz" style="background:#0a0a0c;display:grid;place-items:center">
<div style="text-align:center;color:#9a9aa2;font-size:12px;line-height:1.6">
<svg width="56" height="56" viewBox="0 0 64 64"><path fill="#3ec5a8" d="M33 5c11.5 1 24 11 23 26-1 14-11 27-25 28S6 47 7 31 21 4 33 5z"/><ellipse cx="25.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><ellipse cx="40.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><circle cx="26.8" cy="27.2" r="1.3" fill="#f3f3f6"/><circle cx="41.8" cy="27.2" r="1.3" fill="#f3f3f6"/></svg>
<div>記憶 · 技能 · 你剛說的</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>問模型</h2><p>帶上記憶、技能、你剛說的話。金鑰用你自己的。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="fork">
<div class="lane a">哈囉 → 直接回你</div>
<div class="lane b">「把課看完」→ 開電腦再點</div>
</div>
</div>
<div class="copy"><div class="n">4</div><h2>兩條岔路</h2><p>問候不開機。真的要動手,才叫醒那台 Debian。</p></div>
</article>
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 13</div>
<div class="page-body">
<h3>Quiz</h3>
<p>右側畫面跟著動</p>
<div class="video"></div>
<div class="progress"><b style="width:78%"></b></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy"><div class="n">5</div><h2>寫回來</h2><p>回覆出現在聊天裡。右側畫面跟著動。</p></div>
</article>
</div>
</section>
<section class="slide" id="sleep">
<div class="kicker">電腦的作息</div>
<h1>開著、小睡、關機</h1>
<p class="sub">分頁還在看,它就醒著。沒人看才睡,醒來幾乎不用等。</p>
<div class="col3">
<article class="card dark">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">你還在這個分頁</div>
<div class="page-body">
<h3>熱機中</h3>
<p>每兩秒打一次招呼</p>
<div class="video"></div>
<div class="progress"><b></b></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="n">1</div>
<h2>開著</h2>
<p>記憶體佔著,畫面隨時能進。</p>
<div class="chips"><span class="chip">心跳</span><span class="chip">執行中</span></div>
</div>
</article>
<article class="card">
<div class="viz">
<div class="desk dim">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">離開約 10 分鐘</div>
<div class="page-body">
<h3>凍結</h3>
<p>行程還在記憶體裡</p>
<div class="video"></div>
</div>
</div>
<div class="panelbar"></div>
</div>
<div class="zzz">Zzz</div>
</div>
<div class="copy">
<div class="n">2</div>
<h2>小睡</h2>
<p>像筆電合蓋。下次用,大約一秒內醒來。</p>
<div class="chips"><span class="chip">凍結</span><span class="chip">不要重開桌面</span></div>
</div>
</article>
<article class="card">
<div class="viz">
<div class="desk off">
<div class="house">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#3ec5a8" stroke-width="1.6"><path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M9 20v-6h6v6"/></svg>
<div>家目錄還在</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="n">3</div>
<h2>關機</h2>
<p>約六小時後才真正停掉,把那 2 GB 還你。</p>
<div class="chips"><span class="chip">停機</span><span class="chip">家目錄還在</span></div>
</div>
</article>
</div>
</section>
<section class="slide" id="look">
<div class="kicker">它怎麼「看」螢幕</div>
<h1>你看乾淨的,它看有編號的</h1>
<p class="sub">模型從來不連你的 VNC。它拿一張蓋了黃字的截圖點編號不是猜像素。</p>
<div class="split">
<article class="card">
<div class="copy" style="padding-bottom:8px"><h2>你看到的</h2><p>右側預覽,就是那台 Linux 桌面。沒有黃框、沒有編號。</p></div>
<div class="viz" style="min-height:360px">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>單元 1218 · 看完按 Next</p>
<div class="video"></div>
<ul class="lessons">
<li>11. 防護用具</li>
<li class="on">12. 現場巡視</li>
<li>13. 測驗</li>
</ul>
<div class="progress"><b></b></div>
<div class="next">Next</div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
</article>
<article class="card dark">
<div class="copy" style="padding-bottom:8px"><h2>模型看到的</h2><p>同一瞬間多一層黃字。它說「點 3」就是點那個按鈕。</p></div>
<div class="viz" style="min-height:360px">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>單元 1218 · 看完按 Next</p>
<div class="video"></div>
<ul class="lessons">
<li>11. 防護用具</li>
<li class="on">12. 現場巡視</li>
<li>13. 測驗</li>
</ul>
<div class="progress"><b></b></div>
<div class="next">Next<span class="tag">3</span></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
</article>
</div>
</section>
<section class="slide" id="click">
<div class="kicker">動手的三種方式</div>
<h1>能認控制項,就不要猜座標</h1>
<p class="sub">網頁走瀏覽器、對話框走無障礙樹,實在沒編號才點像素。</p>
<div class="col3">
<article class="card dark">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">mail.example / inbox</div>
<div class="page-body">
<h3>收件匣</h3>
<div class="mail">
<div class="m on">訓練系統 · 請完成單元 12</div>
<div class="m">HR · 本週排班</div>
<div class="m">IT · 密碼即將到期</div>
</div>
<div class="btns">
<div class="next" style="width:78px">Compose<span class="tag">1</span></div>
<div class="next" style="width:64px;background:#444">Reply<span class="tag">2</span></div>
</div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="n">1</div>
<h2>網頁</h2>
<p>Chromium 裡的按鈕、輸入框。點 id會自己捲到看不見的地方。</p>
<div class="chips"><span class="chip">優先</span></div>
</div>
</article>
<article class="card">
<div class="viz" style="background:#2a3340">
<div class="dialog">
<h4>開啟檔案</h4>
<div class="file">report.pdf</div>
<div class="file on">syllabus.pdf</div>
<div class="file">notes.txt</div>
<div class="dlg-btn">開啟<span class="tag">4</span></div>
</div>
</div>
<div class="copy">
<div class="n">2</div>
<h2>桌面視窗</h2>
<p>檔案選取、系統對話框。用無障礙名稱,不是用視窗外框。</p>
<div class="chips"><span class="chip">原生 GUI</span></div>
</div>
</article>
<article class="card">
<div class="viz" style="background:#fff">
<div class="canvas">
<div class="cross"></div>
<div class="xy">640, 400</div>
</div>
</div>
<div class="copy">
<div class="n">3</div>
<h2>座標</h2>
<p>畫布、沒有名字的控制項。最後才用。畫面契約是 1280×800。</p>
<div class="chips"><span class="chip">不得已</span></div>
</div>
</article>
</div>
</section>
<section class="slide" id="teach">
<div class="kicker">教會它</div>
<h1>你做一次,它記住「為什麼」</h1>
<p class="sub">不是錄巨集。它記你點了哪個控制項、填了什麼、去了哪一頁,下次在活的畫面上自己找。</p>
<div class="col4">
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> 你在操作</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>你正在示範</p>
<div class="video"></div>
<div class="next">Next<span class="tag">3</span></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>你示範</h2><p>畫面交給你,用平常的方式做完。密碼欄不會被記。</p></div>
</article>
<article class="card dark">
<div class="viz" style="background:#0a0a0c">
<div class="events">
<div class="ev">click <em>Next</em></div>
<div class="ev">fill <em>姓名</em></div>
<div class="ev">goto <em>/quiz</em></div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>它在旁邊看</h2><p>記語意事件。畫面沒變就不存,避免一堆廢幀。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#eef2ee">
<div class="playbook">
<b>技能草稿</b>
目標:看完課並交測驗<br/>
輸入:學員姓名<br/>
步驟:找到 Next → 填表 → 送出<br/>
完成:出現分數
<div style="margin-top:12px;color:#0f766e;font-size:11px">可改名 · 試跑 · 匯出 JSON</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>整理成技能</h2><p>目標、可變輸入、步驟、怎麼算做完。你可以改名字再存。</p></div>
</article>
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> 下次自己做</div>
<div class="addr">star.example / course / 3</div>
<div class="page-body">
<h3>另一堂課</h3>
<p>在現在的畫面上找 Next</p>
<div class="video"></div>
<div class="next">Next<span class="tag">2</span></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy"><div class="n">4</div><h2>下次自己做</h2><p>找「Next」不是重播上次的座標。</p></div>
</article>
</div>
</section>
<section class="slide" id="schedule">
<div class="kicker">排程</div>
<h1>到點以後,跟你傳訊息同一條路</h1>
<p class="sub">不是另一套引擎。鬧鐘響了,就塞進同一個工作隊列。</p>
<div class="row">
<article class="card">
<div class="viz" style="background:#0d0d0e">
<div class="chat-mini">
<div class="b me">每天九點交報告</div>
<div class="think-row">對話裡講,或側欄自己建</div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>你說「每天九點」</h2><p>五欄時間,預設台北。</p></div>
</article>
<div class="chev"></div>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="alarm">
<svg class="clock" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" fill="#fff" stroke="#ececef" stroke-width="4"/>
<circle cx="60" cy="60" r="4" fill="#111"/>
<line x1="60" y1="60" x2="60" y2="28" stroke="#111" stroke-width="4" stroke-linecap="round"/>
<line x1="60" y1="60" x2="88" y2="60" stroke="#3ec5a8" stroke-width="4" stroke-linecap="round"/>
</svg>
<div class="cron">每天 09:00</div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>鬧鐘記住</h2><p>到點前它只是一列時間。按「立刻跑」也不會把鬧鐘撥亂。</p></div>
</article>
<div class="chev"></div>
<article class="card dark">
<div class="viz" style="background:#0a0a0c">
<div class="stack">
<div class="job on"><span class="dot"></span>09:00 交報告</div>
<div class="job wait">你剛傳的那則</div>
<div class="job wait">下一則等著</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>變成普通工作</h2><p>跟你剛傳的那則訊息一樣:排隊、問模型、必要時開電腦。</p></div>
</article>
</div>
</section>
<section class="slide" id="keys">
<div class="kicker">模型金鑰</div>
<h1>由近到遠,找到第一把就用</h1>
<p class="sub">不管滑鼠。只決定這次要問哪一家模型。</p>
<div class="col3">
<article class="card dark">
<div class="viz" style="background:#0a0a0c">
<div class="form dark">
<label>這個機器人 · 模型金鑰</label>
<div class="field">xai-••••••••••••<span class="ok">用這把</span></div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>這個機器人</h2><p>它自己若存了 key用它的。適合不同 Agent 接不同家。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="form">
<label>本機工作區 · 設定</label>
<div class="field" style="color:#999">尚未填寫</div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>工作區設定</h2><p>本機工作區裡填的。畫面上存的會蓋過環境變數。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="form">
<label>.env</label>
<div class="env-lines">XAI_API_KEY=…<br/>OPENAI_API_KEY=</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>環境變數</h2><p>什麼都沒填才落到這裡。</p></div>
</article>
</div>
</section>
<section class="slide" id="folders">
<div class="kicker">想改程式時</div>
<h1>把它當成幾間房間,不是分層蛋糕</h1>
<p class="sub">從你看得見的畫面往裡走。不必先背 crate 依賴圖。</p>
<div class="rooms">
<div class="room" style="background:#111113;grid-row:1 / span 2">
<div class="path">apps/web</div>
<h2>畫面</h2>
<div class="tiny-ui">
<div style="padding:10px 8px">
<div style="height:8px;width:72%;background:#2a2a2e;border-radius:4px;margin-bottom:10px"></div>
<div style="height:28px;background:#222;border-radius:8px;margin-bottom:8px"></div>
<div style="height:28px;background:#1a1a1d;border-radius:8px"></div>
</div>
<div style="padding:12px 10px;display:flex;flex-direction:column;gap:8px">
<div style="height:8px;width:36%;background:#2a2a2e;border-radius:4px"></div>
<div style="height:26px;width:72%;background:#f1f1ef;border-radius:10px;margin-left:auto"></div>
<div style="height:26px;width:58%;background:#19191c;border-radius:10px"></div>
<div style="height:26px;width:64%;background:#f1f1ef;border-radius:10px;margin-left:auto"></div>
<div style="flex:1"></div>
<div style="height:28px;border:1px solid #2a2a2e;border-radius:14px"></div>
</div>
<div style="background:#101012"></div>
</div>
<small>聊天、電腦預覽、頭像、排程表單。中文在 locales。VNC 在 vnc.html。</small>
</div>
<div class="room" style="background:#0f766e">
<div>
<div class="path">crates/api</div>
<h2>對話與工作</h2>
</div>
<small>送訊息、工具、開機、技能、排程、保險箱。加工具從 tools.rs 開始。</small>
</div>
<div class="room" style="background:#6a6bf5">
<div>
<div class="path">crates/control</div>
<h2>怎麼點</h2>
</div>
<small>編號、CDP、無障礙樹、滑鼠。桌面上的手。</small>
</div>
<div class="room" style="background:#f5a03c;color:#1b1206">
<div>
<div class="path">supervisor + image/computer</div>
<h2>怎麼開機</h2>
</div>
<small>容器的生老病死,以及 XFCEChromium 長什麼樣。</small>
</div>
<div class="room" style="background:#d9508a">
<div>
<div class="path">crates/harness</div>
<h2>問哪一家模型</h2>
</div>
<small>金鑰從哪來、打哪一個網址。不管滑鼠。</small>
</div>
</div>
</section>
<script>
const id = new URLSearchParams(location.search).get("p") || "map";
document.querySelectorAll(".slide").forEach((el) => {
el.classList.toggle("on", el.id === id);
});
</script>
</body>
</html>

BIN
docs/diagrams/chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

BIN
docs/diagrams/click.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

BIN
docs/diagrams/folders.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
docs/diagrams/keys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
docs/diagrams/look.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
docs/diagrams/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
docs/diagrams/schedule.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
docs/diagrams/sleep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

BIN
docs/diagrams/teach.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

View File

@ -0,0 +1,62 @@
# 安全、Harness 與 Docker 檢查
日期2026-09-05。檢查第一方 Rust workspace、前端、控制腳本及容器設定保留工作目錄原有修改。這是程式與依賴檢查不是完整滲透測試reference 第三方專案未逐行稽核。
## 已修正的安全問題
| 風險 | 問題與修正 | 主要位置 |
| --- | --- | --- |
| 高 | 桌面原本持有 supervisor 主 token改為依 home key 衍生的 HMAC token管理路由驗證容器歸屬 | crates/supervisor/src/docker.rs、main.rs |
| 高 | 可提供任意 home 路徑及透過 symlink 越界;限定 DATA_DIR/homes/key附件改用 cap-std 目錄能力讀寫 | supervisor/docker.rs、api/attachments.rs |
| 高 | 已儲存密碼可能填入不相符網站;現在強制指定 hostname 與 HTTPS不自動送出DOM 觀察遮蔽密碼類欄位 | api/tools.rs、control/cdp.py |
| 高 | 桌面提供的 JavaScript 可能在 API 同源執行noVNC 靜態程式改由 API 可信映像提供 | api/screen_proxy.rs、image/api/Dockerfile |
| 中 | 固定登入 cookie 無法個別撤銷;改為隨機 session、伺服器期限及登出撤銷加入 OriginFetch Metadata 與本機 Host 檢查 | api/auth.rs |
| 中 | MCP 子程序繼承服務秘密;改為環境白名單及明確設定 | api/mcp.rs |
| 中 | 工具輸出可能進入記錄;移除輸出摘錄;附件隨機儲存名稱避免覆寫 | api/runs.rs、attachments.rs |
Session 目前放在單一 API 記憶體,重啟失效;不適用多副本共用登入。已建立的 WebSocket 不會因 cookie 撤銷立即關閉。MCP 仍是同 UID 的可信子程序,環境白名單不等於作業系統沙箱。
## Harness 與電腦控制
- 修正 observe 對 POST-only 端點誤用 GET消除正常路徑每次失敗再 fallback。
- 移除有副作用操作失敗後的自動重播;執行前落盤 toolsStarted完成批次保存上下文 checkpoint。worker 在不確定操作是否執行的狀態中斷,會標記失敗而非重做。
- 模型暫時失敗採有限次重試與退避各工具及程序有期限與輸出上限。Checkpoint 移除截圖並限制 1 MiB超限保留不確定狀態以避免盲目重播。這不是 exactly-once 保證。
- 使用 websocket-client 處理 CDP framing、控制訊框與 timeout限制 Chromium 除錯介面為 loopback移除 wildcard Origin。
- DOM snapshot 使用每次唯一 selector降低舊觀察誤點新元素風險點擊前檢查可見、啟用、遮擋動作後等待有限畫面更新。
- 指標/視窗觀察並行;終端剪貼簿使用正確快捷鍵,確認 X11 文字一致後再貼上。
- 排程以交易與 SKIP LOCKED 避免多 worker 重複派發入列與下次時間一併提交。Unix 星期轉換已有回歸測試。DOM 與 DOW 同時受限的 cron 明確拒絕,避免不同 cron 引擎的 OR/AND 語意差異。
這些改動減少多餘往返與重播風險,尚未進行真實桌面延遲 benchmark沒有速度倍數保證。DOM 優先、需要時使用桌面操作仍是本專案適合的路徑;沒有為了換框架重寫整個控制層。
## Docker 改善與升級
已加入 cargo/npm 建置 cache、cargo --locked、npm ci、直接 MCP 套件版本固定、API 非 root、capabilities 限縮、健康檢查、restart/init、程序與日誌上限。資料庫控制網路獨立預設只在 127.0.0.1:3101 開放 APIPostgres 不對主機映射。開發資料庫使用 make postgres 的額外 Compose 設定。
新安裝執行 make env產生四組獨立秘密與隨機資料庫密碼檔案權限 0600。既有 .env 不會被覆寫。
既有安裝更新時:
1. 備份資料庫、data 與 .env。此次僅建置映像沒有重啟你的正式服務、旋轉秘密或刪除既有桌面。
2. 保留原本 vault key。若之前未設定 LAZYBOY_VAULT_KEY先把原本 LAZYBOY_APP_TOKEN 的值保存為 LAZYBOY_VAULT_KEY才能旋轉 app token否則舊密碼可能無法解密。
3. 舊桌面曾取得 supervisor 主 token更新時應更換 LAZYBOY_SUPERVISOR_TOKEN 並重建所有舊桌面容器(保留 home 資料)。新版 controlVersion 會使舊容器在重新 provision 時重建。
4. API 改為 UID 1000。檢查既有 API 資料與快取目錄是否可由 UID 1000 存取;只調整確定需要的目錄,勿遞迴改動所有使用者 home。HOST_DATA_DIR 必須對應正確的主機資料目錄。
5. 既有 Postgres volume 不會因改 .env 自動換密碼密碼輪替須同時更新資料庫角色與連線設定。API 重啟後重新登入。
Supervisor 仍掌握 Docker socket可控制 Docker 主機cap_drop 不能消除此權限。較強隔離方案是專用 Docker daemonVM。桌面 Chromium 既有 --no-sandbox 與可信 MCP 的執行邊界仍需納入威脅模型。基底映像尚未固定 digest未做完整 OS image CVE 掃描;直接套件固定版本不代表所有下載資產都可完全重現。
## 驗證與剩餘告警
- Rust workspace114 個測試通過,含隔離 PostgreSQL 測試;前端 Node 5 個、Python 3 個測試通過。
- 前端 typecheck、production build、程式碼 diff 空白檢查(不含 README 的 Markdown 換行空白、Compose config 驗證通過;三個 Docker 映像實際建置成功。
- 隔離 API smoke test未登入拒絕、跨站登入拒絕、合法登入可存取 API、可信 noVNC asset 可用、登出後舊 cookie 被拒絕;均使用最後建置的映像驗證。
- npm audit0。cargo-auditRUSTSEC-2023-0071rsa 0.9.10,無修補版本)仍在 lockfile但目前啟用依賴樹 cargo tree -i rsa 無結果;不能把未使用的 lockfile 告警說成已移除。
- paste 1.0.15 有停止維護告警 RUSTSEC-2024-0436由 fastembed影像相關上游引入仍需追蹤替代版本。
- 未完成瀏覽器視覺驗收、真實 VNC 剪貼簿端到端、故障注入負載測試。Lottie eval 提醒仍存在。
## 方法參考
- [Docker 安全與 daemon 信任邊界](https://docs.docker.com/engine/security/)、[Docker socket 保護](https://docs.docker.com/engine/security/protect-access/)、[建置快取](https://docs.docker.com/build/cache/optimize/)
- [Playwright actionability](https://playwright.dev/docs/actionability):參考動作前狀態檢查原則,並未導入 Playwright runtime。
- [長時間 agent harness](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents):持久化進度與恢復的設計參考。
- [CDP 協定](https://chromedevtools.github.io/devtools-protocol/)、[websocket-client 用法](https://websocket-client.readthedocs.io/en/latest/examples.html)
- [RSA advisory](https://rustsec.org/advisories/RUSTSEC-2023-0071.html)、[paste advisory](https://rustsec.org/advisories/RUSTSEC-2024-0436.html)

View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Render docs/diagrams.html slides to docs/diagrams/*.png
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
html="$root/docs/diagrams.html"
out="$root/docs/diagrams"
brave="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
mkdir -p "$out"
if [[ ! -x "$brave" ]]; then
echo "Need Brave at $brave" >&2
exit 1
fi
for p in map chat sleep look click teach schedule keys folders; do
"$brave" \
--headless=new \
--disable-gpu \
--hide-scrollbars \
--force-device-scale-factor=2 \
--window-size=1280,720 \
--screenshot="$out/$p.png" \
"file://$html?p=$p"
echo "wrote $out/$p.png"
done

View File

@ -31,3 +31,23 @@ test('saved login rejects HTTP, lookalike hosts, and missing host before touchin
assert.equal(evaluate({username:'u',password:'secret',expectedHost}).ok,false);
}
});
const mdJs=ts.transpileModule(fs.readFileSync('apps/web/src/markdown.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const mdBox={exports:{},require:(name)=>{
if(name==='react')return{useCallback:fn=>fn,useRef:()=>({current:null}),useState:()=>[false,()=>{}],memo:fn=>fn};
if(name==='react/jsx-runtime')return{jsx:()=>null,jsxs:()=>null,Fragment:'Fragment'};
if(name==='react-markdown'||name==='remark-gfm'||name==='remark-breaks')return{default:()=>null};
if(name==='./i18n')return{t:key=>key};
if(name.endsWith('.css'))return{};
throw new Error('unexpected import '+name);
}};
vm.runInNewContext(mdJs,mdBox);
const {sanitizeMarkdownUrl}=mdBox.exports;
test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
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);
});