feat/flow #10
|
|
@ -27,6 +27,12 @@ LAZYBOY_COMPUTER_DRIVER=cua
|
||||||
# Linux only (optional): point this at the host's LXCFS root to make htop/free
|
# Linux only (optional): point this at the host's LXCFS root to make htop/free
|
||||||
# report the per-Agent cgroup quota. Leave the default empty directory on macOS.
|
# report the per-Agent cgroup quota. Leave the default empty directory on macOS.
|
||||||
LAZYBOY_LXCFS_ROOT=./data/lxcfs
|
LAZYBOY_LXCFS_ROOT=./data/lxcfs
|
||||||
|
# 群組聊天「誰該回應」:沒被 @ 的時候,由一次短短的模型問從成員的工作內容與簡介裡
|
||||||
|
# 挑出最多三個人回覆。留空(預設)=沿用空間的預設模型,多數人不用理這行;
|
||||||
|
# 想省 token 就填一個便宜快速的 model id(要跟空間預設來自同一家 provider)。
|
||||||
|
# 挑不到人、模型不能用、或超過 1.2 秒,一律由主持人(群組第一位成員)收場。
|
||||||
|
LAZYBOY_ROUTER_MODEL=
|
||||||
|
|
||||||
# 任務長度政策:不再用固定輪數掐掉任務。正常任務一路做到驗證完成,只有
|
# 任務長度政策:不再用固定輪數掐掉任務。正常任務一路做到驗證完成,只有
|
||||||
# 真的鬼打牆(同一個動作重複、同一個錯誤一直失敗、很久沒有新的成功)才會被
|
# 真的鬼打牆(同一個動作重複、同一個錯誤一直失敗、很久沒有新的成功)才會被
|
||||||
# 提示、接著暫停等你決定;最後兩個是防迴圈失控烧 token 的保險絲,不是額度。
|
# 提示、接著暫停等你決定;最後兩個是防迴圈失控烧 token 的保險絲,不是額度。
|
||||||
|
|
|
||||||
65
README.md
|
|
@ -14,7 +14,7 @@ A self-hosted AI agent workspace. Assign tasks in text or voice, watch the deskt
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again.
|
LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again.
|
||||||
|
|
||||||
|
|
@ -24,8 +24,10 @@ This is an early `0.1.0` release with desktop and phone browser UIs. You bring y
|
||||||
|
|
||||||
- **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory.
|
- **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory.
|
||||||
- **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live.
|
- **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live.
|
||||||
|
- **Attachments in chat**: send files or images along with the message; the agent can open them on its own desktop, and inbox copies expire on their own.
|
||||||
- **Take over any time**: sign in, pass a check, or nudge things by hand on the same desktop, then hand it back.
|
- **Take over any time**: sign in, pass a check, or nudge things by hand on the same desktop, then hand it back.
|
||||||
- **Several agents and groups**: shared Team computers or private dedicated desktops.
|
- **Saved logins**: keep site credentials in the agent's encrypted vault, so it can fill them in at a login wall without the password ever passing through the model.
|
||||||
|
- **Several agents and groups**: shared Team computers or private dedicated desktops; `@name` decides who answers, so a message wakes the one agent it is for instead of all of them.
|
||||||
- **Teach by demo, then schedule**: turn a walkthrough into a skill; use cron for repeat work.
|
- **Teach by demo, then schedule**: turn a walkthrough into a skill; use cron for repeat work.
|
||||||
- **Your models and tools**: xAI, OpenCode Go, OpenAI-compatible endpoints, MCP, and file skills.
|
- **Your models and tools**: xAI, OpenCode Go, OpenAI-compatible endpoints, MCP, and file skills.
|
||||||
- **Voice calls**: after you enable a voice provider, you can talk to the agent on a call.
|
- **Voice calls**: after you enable a voice provider, you can talk to the agent on a call.
|
||||||
|
|
@ -69,7 +71,7 @@ For resource limits, environment variables, HTTPS, and in-container sudo, see [O
|
||||||
|
|
||||||
## On a phone
|
## On a phone
|
||||||
|
|
||||||
Desktop and phone share the same web UI. After you deploy on a host the phone can reach, open that URL in the phone browser. `127.0.0.1` is the phone itself — it will not reach another machine.
|
Desktop and phone share the same web UI. The API listens on `127.0.0.1` by default, so set `LAZYBOY_BIND_IP=0.0.0.0` (or one network card's address) in `.env` and recreate the api container before a phone on your network can reach it; off-loopback the login token has to be at least 32 characters. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine.
|
||||||
|
|
||||||
Tap outside the chat sidebar to collapse it. On the remote desktop you can switch between tap-to-click and trackpad, and use the toolbar for keyboard, right-click, or drag. Put the service behind HTTPS before you expose it; see [Operations](./docs/operations.md#安全模型).
|
Tap outside the chat sidebar to collapse it. On the remote desktop you can switch between tap-to-click and trackpad, and use the toolbar for keyboard, right-click, or drag. Put the service behind HTTPS before you expose it; see [Operations](./docs/operations.md#安全模型).
|
||||||
|
|
||||||
|
|
@ -79,7 +81,7 @@ Tap outside the chat sidebar to collapse it. On the remote desktop you can switc
|
||||||
- **Backend**: Rust 2024, Axum, Tokio
|
- **Backend**: Rust 2024, Axum, Tokio
|
||||||
- **Data**: PostgreSQL, pgvector, SQLx
|
- **Data**: PostgreSQL, pgvector, SQLx
|
||||||
- **Desktop**: Docker, Debian, XFCE, Chromium, Xvfb
|
- **Desktop**: Docker, Debian, XFCE, Chromium, Xvfb
|
||||||
- **Computer control**: CDP, AT-SPI, X11
|
- **Computer control**: Cua Driver over X11, AT-SPI, and Chromium
|
||||||
- **Extensions**: MCP, file skills, demonstration playbooks
|
- **Extensions**: MCP, file skills, demonstration playbooks
|
||||||
|
|
||||||
Flow diagrams, handoff, component roles, and the computer lifecycle live in **[Architecture](./docs/architecture.md)**. The older **[interactive diagram](./docs/workflow.html)** is still there — download it and open it in a browser.
|
Flow diagrams, handoff, component roles, and the computer lifecycle live in **[Architecture](./docs/architecture.md)**. The older **[interactive diagram](./docs/workflow.html)** is still there — download it and open it in a browser.
|
||||||
|
|
@ -108,30 +110,59 @@ Open [http://127.0.0.1:5173](http://127.0.0.1:5173). Tests and the tree layout a
|
||||||
|
|
||||||
The guides below are currently in Traditional Chinese.
|
The guides below are currently in Traditional Chinese.
|
||||||
|
|
||||||
| Doc | Contents |
|
| Doc | Contents |
|
||||||
| --- | --- |
|
| -------------------------------------------------------- | ---------------------------------------------------- |
|
||||||
| [Architecture](./docs/architecture.md) | Task flow, system architecture, computer lifecycle |
|
| [Architecture](./docs/architecture.md) | Task flow, system architecture, computer lifecycle |
|
||||||
| [Interactive diagram](./docs/workflow.html) | Zoomable, searchable HTML chart; download and open |
|
| [Interactive diagram](./docs/workflow.html) | Zoomable, searchable HTML chart; download and open |
|
||||||
| [Operations](./docs/operations.md) | Resources, env vars, security, site checks, sudo |
|
| [Operations](./docs/operations.md) | Resources, env vars, security, site checks, sudo |
|
||||||
| [Agent experience](./docs/agent-experience.md) | Turn limits, persistent terminal, live chat |
|
| [Agent experience](./docs/agent-experience.md) | Turn limits, persistent terminal, live chat |
|
||||||
| [hermes-agent review](./docs/hermes-agent-cua-review.md) | Cua harness smoothness: comparison with hermes-agent |
|
| [hermes-agent review](./docs/hermes-agent-cua-review.md) | Cua harness smoothness: comparison with hermes-agent |
|
||||||
| [Development](./docs/development.md) | Local dev, checks and tests, directory layout |
|
| [Development](./docs/development.md) | Local dev, checks and tests, directory layout |
|
||||||
| [Env example](./.env.example) | Environment variables and defaults |
|
| [Env example](./.env.example) | Environment variables and defaults |
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
|
|
||||||
Chats, memory, browser profiles, and encrypted credentials stay on your host. When you use an external model, the prompts, tool results, and screenshots the task needs may still be sent to that provider.
|
Chats, memory, browser profiles, and encrypted credentials stay on your host. When you use an external model, the prompts, tool results, and screenshots the task needs may still be sent to that provider.
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
LazyBoy is mostly other people's software, carefully assembled. The projects we lean on hardest:
|
||||||
|
|
||||||
|
**Special thanks**
|
||||||
|
|
||||||
|
- **[Cua](https://github.com/trycua/cua)** — the Linux desktop driver behind every click, keystroke, and screenshot. We rebuild `cua-driver-rs` v0.23.2 from source with two small patches kept in this repo: one lets the agent cursor wear the bot's own colour, the other swaps the embedded Latin-only badge font for [jf open Huninn](https://github.com/justfont/open-huninn-font) so Chinese renders. Neither patch touches input handling or permissions.
|
||||||
|
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)** — not a dependency, but the reference we kept returning to while tuning how smooth an agent's desktop should feel. The comparison is written up in the [hermes-agent review](./docs/hermes-agent-cua-review.md).
|
||||||
|
|
||||||
|
**Agents and retrieval** — [rig](https://github.com/0xPlaygrounds/rig) · [rmcp](https://github.com/modelcontextprotocol/rust-sdk) · [fastembed-rs](https://github.com/Anush008/fastembed-rs) and [ONNX Runtime](https://github.com/microsoft/onnxruntime), running [paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2)
|
||||||
|
|
||||||
|
**Rust** — Tokio · Axum · tower-http · SQLx · reqwest · rustls · Bollard · tracing · aes-gcm and hmac from [RustCrypto](https://github.com/RustCrypto) · cap-std · cron · chrono · uuid · thiserror · dotenvy
|
||||||
|
|
||||||
|
**Web** — React · Vite · TypeScript · [noVNC](https://github.com/novnc/noVNC) · react-markdown with [remark-gfm](https://github.com/remarkjs/remark-gfm) and [remark-breaks](https://github.com/remarkjs/remark-breaks) · [Blobatar](https://github.com/Alain00/blobatar) avatars · [react-useanimations](https://github.com/useAnimations/react-useanimations) icons
|
||||||
|
|
||||||
|
**Data** — [PostgreSQL](https://www.postgresql.org) · [pgvector](https://github.com/pgvector/pgvector)
|
||||||
|
|
||||||
|
**The desktop inside each container** — [Docker](https://www.docker.com) · [Debian](https://www.debian.org) · [XFCE](https://www.xfce.org) · [Chromium](https://www.chromium.org) · [Xvfb](https://www.x.org) · Thunar · [x11vnc](https://github.com/LibVNC/x11vnc) · [websockify](https://github.com/novnc/websockify) · [AT-SPI2](https://gitlab.gnome.org/GNOME/at-spi2-core) · [gosu](https://github.com/tianon/gosu) · [LXCFS](https://github.com/lxc/lxcfs) · git · zsh with [Powerlevel10k](https://github.com/romkatv/powerlevel10k) · htop
|
||||||
|
|
||||||
|
**Type and theme** — [jf open Huninn](https://github.com/justfont/open-huninn-font) (SIL OFL) in the UI and on the agent badge · [Noto CJK](https://github.com/notofonts/noto-cjk), DejaVu, and Liberation as fallbacks · [MesloLGS NF](https://github.com/romkatv/powerlevel10k-media) in the terminal · [Arc Dark](https://github.com/horst3180/arc-theme) and [Papirus](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) for the look of the room
|
||||||
|
|
||||||
|
Every version above is pinned in `Cargo.lock`, `apps/web/package.json`, and `image/computer/Dockerfile`. LazyBoy is Apache-2.0; each project keeps its own license. Thanks also to the maintainers whose names never make it into a README, and to everyone who files a good bug report.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="./apps/web/public/lazyboy-icon.png" width="72" height="72" alt="LazyBoy" />
|
||||||
|
|
||||||
|
**LazyBoy is free and open source.** If it hands you back an afternoon, a coffee is the nicest way to say so.
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/daniel.wang.1993"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="48" /></a>
|
||||||
|
|
||||||
|
<img src="https://img.shields.io/badge/license-Apache--2.0-2f9e8f?style=for-the-badge&labelColor=1b1b22" alt="Apache License 2.0" />
|
||||||
|
<img src="https://img.shields.io/badge/release-0.1.0-2f9e8f?style=for-the-badge&labelColor=1b1b22" alt="Release 0.1.0" />
|
||||||
|
<img src="https://img.shields.io/badge/Rust%20%2B%20React-6f9c96?style=for-the-badge&labelColor=1b1b22" alt="Rust and React" />
|
||||||
|
|
||||||
**Daniel Wang** <img src="https://flagcdn.com/w20/tw.png" width="20" alt="Taiwan" />
|
**Daniel Wang** <img src="https://flagcdn.com/w20/tw.png" width="20" alt="Taiwan" />
|
||||||
|
|
||||||
[igs170911@gmail.com](mailto:igs170911@gmail.com)
|
[igs170911@gmail.com](mailto:igs170911@gmail.com) · [Apache License 2.0](./LICENSE)
|
||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/daniel.wang.1993"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="50" /></a>
|
|
||||||
|
|
||||||
[Apache License 2.0](./LICENSE)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作,把示範整理成技能,再安排定時執行。
|
LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作,把示範整理成技能,再安排定時執行。
|
||||||
|
|
||||||
|
|
@ -24,8 +24,10 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
|
||||||
|
|
||||||
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
|
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
|
||||||
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
|
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
|
||||||
|
- **附件直接丟進對話**:訊息可帶檔案或圖片,Agent 能在自己的桌面開啟,放在收件匣的複本會自動到期清除。
|
||||||
- **隨時人工接管**:在同一個桌面完成登入、驗證或手動調整,再交回 Agent。
|
- **隨時人工接管**:在同一個桌面完成登入、驗證或手動調整,再交回 Agent。
|
||||||
- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式。
|
- **登入帳密進保險庫**:帳密加密存在 Agent 自己的保險庫,遇到登入頁由它填寫,密碼不會經過模型。
|
||||||
|
- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式;群組裡 @誰就由誰回,沒點名時只叫醒工作內容相關的那個,不會全部出動。
|
||||||
- **示範教學與排程**:把操作示範整理成技能,使用 cron 安排重複工作。
|
- **示範教學與排程**:把操作示範整理成技能,使用 cron 安排重複工作。
|
||||||
- **自選模型與工具**:支援 xAI、OpenCode Go、OpenAI 相容端點,以及 MCP 與檔案技能。
|
- **自選模型與工具**:支援 xAI、OpenCode Go、OpenAI 相容端點,以及 MCP 與檔案技能。
|
||||||
- **語音通話**:設定語音服務後,可以透過通話與 Agent 互動。
|
- **語音通話**:設定語音服務後,可以透過通話與 Agent 互動。
|
||||||
|
|
@ -69,7 +71,7 @@ make down # 停止服務,保留 PostgreSQL 資料
|
||||||
|
|
||||||
## 在手機上使用
|
## 在手機上使用
|
||||||
|
|
||||||
手機與桌面使用同一個 Web 介面。將服務部署在可連線的主機後,用手機瀏覽器開啟該服務的網址即可;`127.0.0.1` 只代表手機本身,不能拿來連另一台電腦。
|
手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到;綁非 loopback 時登入 token 需至少 32 字元。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦。
|
||||||
|
|
||||||
聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS,詳見 [部署指南](./docs/operations.md#安全模型)。
|
聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS,詳見 [部署指南](./docs/operations.md#安全模型)。
|
||||||
|
|
||||||
|
|
@ -79,7 +81,7 @@ make down # 停止服務,保留 PostgreSQL 資料
|
||||||
- **後端**:Rust 2024、Axum、Tokio
|
- **後端**:Rust 2024、Axum、Tokio
|
||||||
- **資料**:PostgreSQL、pgvector、SQLx
|
- **資料**:PostgreSQL、pgvector、SQLx
|
||||||
- **桌面**:Docker、Debian、XFCE、Chromium、Xvfb
|
- **桌面**:Docker、Debian、XFCE、Chromium、Xvfb
|
||||||
- **電腦控制**:CDP、AT-SPI、X11
|
- **電腦控制**:Cua Driver(X11、AT-SPI、Chromium)
|
||||||
- **擴充**:MCP、檔案技能、示範 playbook
|
- **擴充**:MCP、檔案技能、示範 playbook
|
||||||
|
|
||||||
流程圖、控制權交接、元件職責與電腦生命週期狀態機,集中在 **[架構與流程](./docs/architecture.md)**。原有的 **[互動流程圖](./docs/workflow.html)** 也保留;下載後用瀏覽器開啟即可操作。
|
流程圖、控制權交接、元件職責與電腦生命週期狀態機,集中在 **[架構與流程](./docs/architecture.md)**。原有的 **[互動流程圖](./docs/workflow.html)** 也保留;下載後用瀏覽器開啟即可操作。
|
||||||
|
|
@ -112,6 +114,7 @@ npm run dev
|
||||||
| [互動流程圖](./docs/workflow.html) | 可縮放、搜尋的 HTML 圖表;下載後開啟 |
|
| [互動流程圖](./docs/workflow.html) | 可縮放、搜尋的 HTML 圖表;下載後開啟 |
|
||||||
| [部署與操作](./docs/operations.md) | 資源、環境變數、安全設定、網站驗證、sudo |
|
| [部署與操作](./docs/operations.md) | 資源、環境變數、安全設定、網站驗證、sudo |
|
||||||
| [AI 使用體驗](./docs/agent-experience.md) | 輪次政策、持久終端機、聊天即時推送 |
|
| [AI 使用體驗](./docs/agent-experience.md) | 輪次政策、持久終端機、聊天即時推送 |
|
||||||
|
| [hermes-agent 比較](./docs/hermes-agent-cua-review.md) | Cua 操作流暢度:與 hermes-agent 對照 |
|
||||||
| [開發指南](./docs/development.md) | 本機開發、檢查與測試、目錄結構 |
|
| [開發指南](./docs/development.md) | 本機開發、檢查與測試、目錄結構 |
|
||||||
| [設定範例](./.env.example) | 環境變數與預設值 |
|
| [設定範例](./.env.example) | 環境變數與預設值 |
|
||||||
|
|
||||||
|
|
@ -119,16 +122,45 @@ npm run dev
|
||||||
|
|
||||||
對話、記憶、瀏覽器設定檔與加密憑證保存在自架主機。使用外部模型時,任務所需的提示詞、工具結果與截圖仍可能傳送給該模型供應商。
|
對話、記憶、瀏覽器設定檔與加密憑證保存在自架主機。使用外部模型時,任務所需的提示詞、工具結果與截圖仍可能傳送給該模型供應商。
|
||||||
|
|
||||||
|
## 特別感謝
|
||||||
|
|
||||||
|
LazyBoy 站在許多開源專案的肩上,下面是我們用到最多、也最想致謝的專案。
|
||||||
|
|
||||||
|
**首要致謝**
|
||||||
|
|
||||||
|
- **[Cua](https://github.com/trycua/cua)**:每一次點擊、鍵盤輸入與截圖,都由它的 Linux 桌面驅動完成。我們從原始碼重建 `cua-driver-rs` v0.23.2,並在本專案內維護兩處小修補:一處讓 Agent 游標套用機器人自己的顏色,另一處把內嵌的純英文徽章字型換成 [jf open Huninn](https://github.com/justfont/open-huninn-font),讓中文能正確顯示。兩處修補都不碰輸入處理與權限邏輯。
|
||||||
|
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)**:它不是相依套件,卻是我們調整操作流暢度時一再回頭參照的對象。對照分析見 [hermes-agent 比較](./docs/hermes-agent-cua-review.md)。
|
||||||
|
|
||||||
|
**Agent 與記憶檢索**:[rig](https://github.com/0xPlaygrounds/rig) · [rmcp](https://github.com/modelcontextprotocol/rust-sdk) · [fastembed-rs](https://github.com/Anush008/fastembed-rs) 與 [ONNX Runtime](https://github.com/microsoft/onnxruntime),嵌入模型為 [paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2)
|
||||||
|
|
||||||
|
**Rust**:Tokio · Axum · tower-http · SQLx · reqwest · rustls · Bollard · tracing · [RustCrypto](https://github.com/RustCrypto) 的 aes-gcm 與 hmac · cap-std · cron · chrono · uuid · thiserror · dotenvy
|
||||||
|
|
||||||
|
**前端**:React · Vite · TypeScript · [noVNC](https://github.com/novnc/noVNC) · react-markdown 搭配 [remark-gfm](https://github.com/remarkjs/remark-gfm) 與 [remark-breaks](https://github.com/remarkjs/remark-breaks) · [Blobatar](https://github.com/Alain00/blobatar) 頭像 · [react-useanimations](https://github.com/useAnimations/react-useanimations) 圖示
|
||||||
|
|
||||||
|
**資料**:[PostgreSQL](https://www.postgresql.org) · [pgvector](https://github.com/pgvector/pgvector)
|
||||||
|
|
||||||
|
**容器裡的桌面**:[Docker](https://www.docker.com) · [Debian](https://www.debian.org) · [XFCE](https://www.xfce.org) · [Chromium](https://www.chromium.org) · [Xvfb](https://www.x.org) · Thunar · [x11vnc](https://github.com/LibVNC/x11vnc) · [websockify](https://github.com/novnc/websockify) · [AT-SPI2](https://gitlab.gnome.org/GNOME/at-spi2-core) · [gosu](https://github.com/tianon/gosu) · [LXCFS](https://github.com/lxc/lxcfs) · git · 搭配 [Powerlevel10k](https://github.com/romkatv/powerlevel10k) 的 zsh · htop
|
||||||
|
|
||||||
|
**字型與佈景主題**:介面與 Agent 徽章使用 [jf open Huninn](https://github.com/justfont/open-huninn-font)(SIL OFL)· 備援字型為 [Noto CJK](https://github.com/notofonts/noto-cjk)、DejaVu 與 Liberation · 終端機使用 [MesloLGS NF](https://github.com/romkatv/powerlevel10k-media) · 外觀採用 [Arc Dark](https://github.com/horst3180/arc-theme) 與 [Papirus](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||||
|
|
||||||
|
以上版本都釘選在 `Cargo.lock`、`apps/web/package.json` 與 `image/computer/Dockerfile`。LazyBoy 以 Apache-2.0 釋出,上述每個專案保留各自的授權條款。也謝謝那些名字從不出現在 README 裡的維護者,以及每一位認真回報問題的使用者。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="./apps/web/public/lazyboy-icon.png" width="72" height="72" alt="LazyBoy" />
|
||||||
|
|
||||||
|
**LazyBoy 是免費的開源專案。** 如果它幫你省下一個下午,一杯咖啡是最好的回禮。
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/daniel.wang.1993"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="請我喝杯咖啡" height="48" /></a>
|
||||||
|
|
||||||
|
<img src="https://img.shields.io/badge/license-Apache--2.0-2f9e8f?style=for-the-badge&labelColor=1b1b22" alt="Apache License 2.0" />
|
||||||
|
<img src="https://img.shields.io/badge/release-0.1.0-2f9e8f?style=for-the-badge&labelColor=1b1b22" alt="Release 0.1.0" />
|
||||||
|
<img src="https://img.shields.io/badge/Rust%20%2B%20React-6f9c96?style=for-the-badge&labelColor=1b1b22" alt="Rust and React" />
|
||||||
|
|
||||||
**danielwang** <img src="https://flagcdn.com/w20/tw.png" width="20" alt="Taiwan" />
|
**danielwang** <img src="https://flagcdn.com/w20/tw.png" width="20" alt="Taiwan" />
|
||||||
|
|
||||||
[igs170911@gmail.com](mailto:igs170911@gmail.com)
|
[igs170911@gmail.com](mailto:igs170911@gmail.com) · [Apache License 2.0](./LICENSE)
|
||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/daniel.wang.1993"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="請我喝杯咖啡" height="50" /></a>
|
|
||||||
|
|
||||||
[Apache License 2.0](./LICENSE)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* How a chat shows when something was said: the day is written once, as a
|
||||||
|
* divider above the first message of that day, and each message carries only
|
||||||
|
* its clock time. A day is named the way people say it — 今天, 昨天 — and
|
||||||
|
* spelled out only when it is further away.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DayWords {
|
||||||
|
today: string;
|
||||||
|
yesterday: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local calendar day, so two timestamps on the same date compare equal. */
|
||||||
|
export function dayKey(value: string | Date): string {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return "";
|
||||||
|
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameDay(a: string | Date, b: string | Date): boolean {
|
||||||
|
const keyA = dayKey(a);
|
||||||
|
return keyA !== "" && keyA === dayKey(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole local days between two moments; negative when `date` is in the future. */
|
||||||
|
function daysBefore(date: Date, now: Date): number {
|
||||||
|
const start = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
||||||
|
return Math.round((start(now) - start(date)) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The divider text for a day: 今天 / 昨天, then a weekday for the past week
|
||||||
|
* (people remember "Wednesday" before "the 3rd"), then a short date, and the
|
||||||
|
* year only when it is not this one.
|
||||||
|
*/
|
||||||
|
export function dayLabel(value: string | Date, now: Date, locale: string, words: DayWords): string {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return "";
|
||||||
|
const ago = daysBefore(date, now);
|
||||||
|
if (ago === 0) return words.today;
|
||||||
|
if (ago === 1) return words.yesterday;
|
||||||
|
if (ago > 1 && ago < 7) return date.toLocaleDateString(locale, { weekday: "long" });
|
||||||
|
if (date.getFullYear() === now.getFullYear()) {
|
||||||
|
return date.toLocaleDateString(locale, { month: "numeric", day: "numeric", weekday: "short" });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString(locale, { year: "numeric", month: "numeric", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The clock time a message shows next to itself; the day lives in the divider. */
|
||||||
|
export function clockTime(value: string | Date, locale: string): string {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return "";
|
||||||
|
return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
}
|
||||||
|
|
@ -77,3 +77,31 @@
|
||||||
.message{padding-bottom:21px}
|
.message{padding-bottom:21px}
|
||||||
.message-time{position:absolute;bottom:0;left:0;font-size:11px;line-height:17px;color:var(--muted);font-variant-numeric:tabular-nums}
|
.message-time{position:absolute;bottom:0;left:0;font-size:11px;line-height:17px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||||
.message.user .message-time{left:auto;right:28px}
|
.message.user .message-time{left:auto;right:28px}
|
||||||
|
|
||||||
|
/* The day is said once, centred above the first message of that day, so each
|
||||||
|
message only needs its clock time. */
|
||||||
|
.day-divider{display:flex;justify-content:center;width:var(--chat-col);margin:6px auto -4px;user-select:none}
|
||||||
|
.day-divider time{padding:3px 11px;border-radius:999px;background:rgba(255,255,255,.06);color:var(--muted);font-size:11px;line-height:16px;letter-spacing:.02em}
|
||||||
|
|
||||||
|
/* Who the room sent a message to: one thin line in the space under the bubble,
|
||||||
|
opposite the timestamp, so it reads as a note about the message and never as
|
||||||
|
a second message. */
|
||||||
|
.route-note{position:absolute;bottom:0;left:0;max-width:56%;overflow:hidden;color:var(--muted);font-size:11px;line-height:17px;opacity:.78;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.message.user .route-note{left:0;right:auto}
|
||||||
|
.message.assistant .route-note{left:auto;right:28px;text-align:right}
|
||||||
|
|
||||||
|
/* The `@` list sits where the slash list sits: only one of them is ever open. */
|
||||||
|
.mention-suggestions button{align-items:center;gap:8px}
|
||||||
|
.mention-suggestions strong{min-width:0}
|
||||||
|
|
||||||
|
/* The host is a name in the top bar, not a page of settings. */
|
||||||
|
.host-chip-wrap{position:relative;display:inline-flex;align-items:center}
|
||||||
|
.host-chip{display:inline-flex;align-items:center;gap:5px;max-width:150px;padding:3px 9px;border:1px solid var(--border);border-radius:999px;background:transparent;color:var(--muted);font-size:11px;cursor:pointer}
|
||||||
|
.host-chip:hover,.host-chip[aria-expanded="true"]{border-color:var(--accent);color:var(--ink)}
|
||||||
|
.host-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.host-menu{position:absolute;top:calc(100% + 8px);left:0;z-index:9;display:grid;gap:2px;min-width:190px;padding:8px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35)}
|
||||||
|
.host-menu button{display:flex;align-items:center;gap:8px;padding:7px 9px;border:0;border-radius:9px;background:transparent;color:var(--ink);font-size:13px;text-align:left;cursor:pointer}
|
||||||
|
.host-menu button:hover,.host-menu button:focus-visible{background:rgba(255,255,255,.08);outline:0}
|
||||||
|
.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}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,11 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
chooseBot: "Choose a bot",
|
chooseBot: "Choose a bot",
|
||||||
startRoomDiscussion: "Start a discussion with {name}",
|
startRoomDiscussion: "Start a discussion with {name}",
|
||||||
startBotWork: "Start working with {name}",
|
startBotWork: "Start working with {name}",
|
||||||
roomWillReply: "{names} will all reply.",
|
roomWillReply: "{names} are here. Whoever is @-mentioned answers; otherwise the host, or the best fit, picks it up.",
|
||||||
|
everyoneMention: "everyone", mentionEveryoneHint: "every member answers", mentionList: "Members to mention",
|
||||||
|
routedTo: "{names} will answer this", handedTo: "→ handed to {name}",
|
||||||
|
today: "Today", yesterday: "Yesterday",
|
||||||
|
hostBadge: "Host", hostMenuLabel: "Who hosts", hostHint: "The host answers what nobody is named for. Tap to change it.",
|
||||||
botWelcome: "Send a message and it will get it done on its own computer.",
|
botWelcome: "Send a message and it will get it done on its own computer.",
|
||||||
remembered: "Remembered",
|
remembered: "Remembered",
|
||||||
remember: "Remember",
|
remember: "Remember",
|
||||||
|
|
@ -292,6 +296,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
privateComputerHint: "A fresh dedicated Docker desktop",
|
privateComputerHint: "A fresh dedicated Docker desktop",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
groupDescription: "A group opens one chat where every selected agent speaks.",
|
groupDescription: "A group opens one chat where every selected agent speaks.",
|
||||||
|
groupHostHint: "{name} starts as host — one tap in the top bar swaps it.",
|
||||||
groupName: "Group name",
|
groupName: "Group name",
|
||||||
groupNamePlaceholder: "For example: product research",
|
groupNamePlaceholder: "For example: product research",
|
||||||
chooseBots: "Choose bots (at least two)",
|
chooseBots: "Choose bots (at least two)",
|
||||||
|
|
@ -340,7 +345,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
helpBotsTitle: "Bots",
|
helpBotsTitle: "Bots",
|
||||||
helpBots: "Use + at the top left to add a bot. Click the list to chat. Right-click to pin, hide, or delete.",
|
helpBots: "Use + at the top left to add a bot. Click the list to chat. Right-click to pin, hide, or delete.",
|
||||||
helpGroupsTitle: "Groups",
|
helpGroupsTitle: "Groups",
|
||||||
helpGroups: "+ → New group, pick at least two. One message, every agent in the group replies.",
|
helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.",
|
||||||
helpComputerTitle: "Computer",
|
helpComputerTitle: "Computer",
|
||||||
helpComputer: "The Computer tab on the right is this agent’s desktop. Start it, take over mouse and keyboard, or let it drive.",
|
helpComputer: "The Computer tab on the right is this agent’s desktop. Start it, take over mouse and keyboard, or let it drive.",
|
||||||
helpMemoryTitle: "Memory",
|
helpMemoryTitle: "Memory",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ export const zhTW = {
|
||||||
unreadMessages: "{count} 則未讀訊息", members: "{count} 位成員", hideHiddenItems: "隱藏已隱藏項目", showHiddenItems: "顯示已隱藏項目",
|
unreadMessages: "{count} 則未讀訊息", members: "{count} 位成員", hideHiddenItems: "隱藏已隱藏項目", showHiddenItems: "顯示已隱藏項目",
|
||||||
mcpConnected: "{count} 個 MCP 已連線", connectMcpServer: "接入 MCP server",
|
mcpConnected: "{count} 個 MCP 已連線", connectMcpServer: "接入 MCP server",
|
||||||
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
|
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
|
||||||
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。",
|
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 都在這裡。@誰就由誰回覆,沒點名時由主持人或最合適的 Agent 接手。",
|
||||||
|
everyoneMention: "所有人", mentionEveryoneHint: "每個人都回覆", mentionList: "可點名的成員",
|
||||||
|
routedTo: "本輪由 {names} 回覆", handedTo: "→ 交給 {name}",
|
||||||
|
today: "今天", yesterday: "昨天",
|
||||||
|
hostBadge: "主持人", hostMenuLabel: "由誰主持", hostHint: "沒被點名的時候由主持人回答。點一下換人。",
|
||||||
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
|
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
|
||||||
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
|
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
|
||||||
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
|
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
|
||||||
|
|
@ -93,7 +97,7 @@ export const zhTW = {
|
||||||
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
|
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
|
||||||
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
|
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
|
||||||
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
|
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
|
||||||
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
|
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupHostHint: "第一位({name})先擔任主持人,以後在上方一點就能換。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
|
||||||
deleteGroup: "刪除群組", deleteNamed: "刪除 {name}?", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。",
|
deleteGroup: "刪除群組", deleteNamed: "刪除 {name}?", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。",
|
||||||
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP,例如 http://192.168.x.x:3101。",
|
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP,例如 http://192.168.x.x:3101。",
|
||||||
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
|
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
|
||||||
|
|
@ -113,7 +117,7 @@ export const zhTW = {
|
||||||
workspaceModelHint: "先選供應商,再填金鑰與模型。OpenAI 相容需要自己填端點。",
|
workspaceModelHint: "先選供應商,再填金鑰與模型。OpenAI 相容需要自己填端點。",
|
||||||
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
|
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
|
||||||
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
||||||
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
|
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
|
||||||
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
|
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
|
||||||
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
|
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
|
||||||
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
|
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { RoomMember } from "./types";
|
||||||
|
|
||||||
|
/** One row of the `@` list. A null `member` is the whole-room escape hatch. */
|
||||||
|
export interface MentionChoice {
|
||||||
|
name: string;
|
||||||
|
member: RoomMember | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `@`-token under the caret, or null when the caret is not writing one.
|
||||||
|
* A token ends at a space, so `@小美 幫我看` stops offering the list the moment
|
||||||
|
* the name is finished, and `mail@example.com` never opens it.
|
||||||
|
*/
|
||||||
|
export function mentionToken(text: string, caret: number): string | null {
|
||||||
|
const end = Math.max(0, Math.min(caret, text.length));
|
||||||
|
const hit = /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, end));
|
||||||
|
return hit ? hit[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who the token could mean, in room order, capped so the list stays one glance.
|
||||||
|
* `everyone` — the one name that reaches the whole room — leads the list while
|
||||||
|
* the token is empty or could still become it, because typing `@` is where a
|
||||||
|
* person discovers that escape hatch.
|
||||||
|
*/
|
||||||
|
export function mentionChoices(
|
||||||
|
token: string,
|
||||||
|
members: RoomMember[],
|
||||||
|
everyone: string,
|
||||||
|
): MentionChoice[] {
|
||||||
|
const needle = token.trim().toLowerCase();
|
||||||
|
const choices: MentionChoice[] = [];
|
||||||
|
if (everyone.toLowerCase().startsWith(needle)) {
|
||||||
|
choices.push({ name: everyone, member: null });
|
||||||
|
}
|
||||||
|
for (const member of members) {
|
||||||
|
if (member.name.toLowerCase().includes(needle)) choices.push({ name: member.name, member });
|
||||||
|
}
|
||||||
|
return choices.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the token being typed with a chosen name and one space, leaving the
|
||||||
|
* rest of the message where it was. The new caret sits after that space, so the
|
||||||
|
* next keystroke continues the sentence instead of reopening the list.
|
||||||
|
*/
|
||||||
|
export function acceptMention(
|
||||||
|
text: string,
|
||||||
|
caret: number,
|
||||||
|
name: string,
|
||||||
|
): { text: string; caret: number } {
|
||||||
|
const end = Math.max(0, Math.min(caret, text.length));
|
||||||
|
const head = text.slice(0, end).replace(/@([^\s@]*)$/, "");
|
||||||
|
const inserted = `${head}@${name} `;
|
||||||
|
return { text: inserted + text.slice(end), caret: inserted.length };
|
||||||
|
}
|
||||||
|
|
@ -620,6 +620,8 @@
|
||||||
|
|
||||||
|
|
||||||
.dialog-lead{margin:0;color:var(--muted);line-height:1.5;font-size:13px}
|
.dialog-lead{margin:0;color:var(--muted);line-height:1.5;font-size:13px}
|
||||||
|
/* One quiet sentence under a form: what will happen by default, so the human has nothing to configure. */
|
||||||
|
.dialog-note{margin:-4px 0 0;color:var(--muted);font-size:12px;line-height:1.5}
|
||||||
|
|
||||||
|
|
||||||
@keyframes computer-hud-bob{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-3px) rotate(5deg)}}
|
@keyframes computer-hud-bob{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-3px) rotate(5deg)}}
|
||||||
|
|
|
||||||
|
|
@ -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 16px}.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: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 16px}.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: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: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,.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:16px}}
|
@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}.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}}
|
||||||
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
|
@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: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){
|
@media(max-width:1050px){
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ export type BlobatarShape = "round"|"organic"|"boxy"|"capsule"|"nub"|"cloud"|"dr
|
||||||
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 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 }
|
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 }
|
||||||
export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number }
|
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 }
|
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 { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
|
||||||
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
|
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
|
||||||
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
|
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 }
|
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 }
|
||||||
/** One line of the live trail a run writes while it works. */
|
/** One line of the live trail a run writes while it works. */
|
||||||
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice"|"memory";
|
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice"|"memory";
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ mod monitor;
|
||||||
mod retention;
|
mod retention;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod routing;
|
||||||
mod runs;
|
mod runs;
|
||||||
mod schedules;
|
mod schedules;
|
||||||
mod screen_proxy;
|
mod screen_proxy;
|
||||||
|
|
|
||||||
|
|
@ -1280,4 +1280,91 @@ mod tests {
|
||||||
.id
|
.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A conversation that holds remembered lines can still be removed: the
|
||||||
|
/// memory survives, pointing at nothing, and the guard keeps rejecting a
|
||||||
|
/// genuine move into another agent's conversation.
|
||||||
|
#[sqlx::test(migrations = "../../migrations")]
|
||||||
|
async fn deleting_a_conversation_keeps_its_memories_and_the_guard(pool: sqlx::PgPool) {
|
||||||
|
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();
|
||||||
|
for bot in ["a", "b"] {
|
||||||
|
sqlx::query("INSERT INTO bots(id,space_id,user_id,name) VALUES ($1,'s','u',$1)")
|
||||||
|
.bind(bot)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ($1,'s','u',$2)")
|
||||||
|
.bind(format!("thread-{bot}"))
|
||||||
|
.bind(bot)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,status,prompt)
|
||||||
|
VALUES ('run-a','s','u','a','thread-a','completed','hi')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO messages(id,thread_id,seq,role,body,run_id)
|
||||||
|
VALUES ('msg-a','thread-a',1,'user','remember this','run-a')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// One saved from the chat (message + session), one by the remember tool
|
||||||
|
// (run + session): both link into the conversation being removed.
|
||||||
|
let from_chat = Uuid::new_v4();
|
||||||
|
let from_tool = Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,source_run_id,source_message_id,content)
|
||||||
|
VALUES ($1,'s','u','a','thread-a',NULL,'msg-a','saved from chat'),
|
||||||
|
($2,'s','u','a','thread-a','run-a',NULL,'saved by tool')",
|
||||||
|
)
|
||||||
|
.bind(from_chat)
|
||||||
|
.bind(from_tool)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM threads WHERE id='thread-a'")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("a conversation with remembered lines can be deleted");
|
||||||
|
let left: Vec<(Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT session_id, source_run_id, source_message_id FROM memory_items
|
||||||
|
WHERE bot_id='a' ORDER BY content",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left, vec![(None, None, None), (None, None, None)]);
|
||||||
|
|
||||||
|
// The guard still holds: memory cannot be pointed at another agent's
|
||||||
|
// conversation after the fact.
|
||||||
|
let moved = sqlx::query("UPDATE memory_items SET session_id='thread-b' WHERE id=$1")
|
||||||
|
.bind(from_chat)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
moved.is_err(),
|
||||||
|
"re-homing into another agent's thread must fail"
|
||||||
|
);
|
||||||
|
let kept: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT count(*) FROM memory_items WHERE bot_id='a' AND session_id IS NULL",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(kept, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::http::StatusCode;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use lazyboy_contracts::{
|
use lazyboy_contracts::{
|
||||||
CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session,
|
CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session, UpdateRoomInput,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -19,7 +19,10 @@ type ApiError = (StatusCode, Json<Value>);
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/rooms", get(list_rooms).post(create_room))
|
.route("/api/rooms", get(list_rooms).post(create_room))
|
||||||
.route("/api/rooms/{id}", get(get_room).delete(delete_room))
|
.route(
|
||||||
|
"/api/rooms/{id}",
|
||||||
|
get(get_room).patch(update_room).delete(delete_room),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/rooms/{id}/sessions",
|
"/api/rooms/{id}/sessions",
|
||||||
get(list_sessions).post(create_session),
|
get(list_sessions).post(create_session),
|
||||||
|
|
@ -64,10 +67,12 @@ async fn members_for(state: &AppState, room_id: &str) -> Result<Vec<RoomMember>,
|
||||||
Ok(rows.into_iter().map(member_from_row).collect())
|
Ok(rows.into_iter().map(member_from_row).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `room_from_id` projection: id, name, last message time, preview, unread count.
|
/// `room_from_id` projection: id, name, host, last message time, preview, unread
|
||||||
|
/// count.
|
||||||
type RoomSummaryRow = (
|
type RoomSummaryRow = (
|
||||||
String,
|
String,
|
||||||
String,
|
String,
|
||||||
|
Option<String>,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
i64,
|
i64,
|
||||||
|
|
@ -79,7 +84,7 @@ async fn room_from_id(
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<Option<Room>, sqlx::Error> {
|
) -> Result<Option<Room>, sqlx::Error> {
|
||||||
let row: Option<RoomSummaryRow> = sqlx::query_as(
|
let row: Option<RoomSummaryRow> = sqlx::query_as(
|
||||||
"SELECT r.id, r.name,
|
"SELECT r.id, r.name, r.host_bot_id,
|
||||||
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id),
|
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id),
|
||||||
(SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id
|
(SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id
|
||||||
WHERE t.room_id=r.id ORDER BY m.created_at DESC, m.seq DESC LIMIT 1),
|
WHERE t.room_id=r.id ORDER BY m.created_at DESC, m.seq DESC LIMIT 1),
|
||||||
|
|
@ -96,13 +101,14 @@ async fn room_from_id(
|
||||||
.bind(&actor.user_id)
|
.bind(&actor.user_id)
|
||||||
.fetch_optional(state.pool())
|
.fetch_optional(state.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let Some((id, name, last_message_at, last_preview, unread_count)) = row else {
|
let Some((id, name, host_bot_id, last_message_at, last_preview, unread_count)) = row else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(Some(Room {
|
Ok(Some(Room {
|
||||||
members: members_for(state, &id).await?,
|
members: members_for(state, &id).await?,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
host_bot_id,
|
||||||
last_message_at,
|
last_message_at,
|
||||||
last_preview,
|
last_preview,
|
||||||
unread_count,
|
unread_count,
|
||||||
|
|
@ -149,6 +155,15 @@ async fn create_room(
|
||||||
Json(json!({"message":"群組需要名稱,並至少兩位機器人"})),
|
Json(json!({"message":"群組需要名稱,並至少兩位機器人"})),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let host_bot_id = input.host_bot_id;
|
||||||
|
if let Some(host) = host_bot_id.as_deref()
|
||||||
|
&& !member_ids.iter().any(|member| member.as_str() == host)
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"message":"主持人必須是群組成員"})),
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut members = Vec::new();
|
let mut members = Vec::new();
|
||||||
for bot_id in &member_ids {
|
for bot_id in &member_ids {
|
||||||
let exists: Option<(String, String, String, String)> = sqlx::query_as(
|
let exists: Option<(String, String, String, String)> = sqlx::query_as(
|
||||||
|
|
@ -170,17 +185,20 @@ async fn create_room(
|
||||||
members.push(member_from_row(row));
|
members.push(member_from_row(row));
|
||||||
}
|
}
|
||||||
let room_id = Uuid::new_v4().to_string();
|
let room_id = Uuid::new_v4().to_string();
|
||||||
let host_id = members[0].id.clone();
|
// The host is decided once, here: the member the human chose, or simply the
|
||||||
|
// first one. Starting a group needs no other setting.
|
||||||
|
let host_id = host_bot_id.unwrap_or_else(|| members[0].id.clone());
|
||||||
let mut tx = state
|
let mut tx = state
|
||||||
.pool()
|
.pool()
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?;
|
.map_err(|error| internal(error.to_string()))?;
|
||||||
sqlx::query("INSERT INTO rooms (id,space_id,user_id,name) VALUES ($1,$2,$3,$4)")
|
sqlx::query("INSERT INTO rooms (id,space_id,user_id,name,host_bot_id) VALUES ($1,$2,$3,$4,$5)")
|
||||||
.bind(&room_id)
|
.bind(&room_id)
|
||||||
.bind(&actor.space_id)
|
.bind(&actor.space_id)
|
||||||
.bind(&actor.user_id)
|
.bind(&actor.user_id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
|
.bind(&host_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?;
|
.map_err(|error| internal(error.to_string()))?;
|
||||||
|
|
@ -213,6 +231,7 @@ async fn create_room(
|
||||||
id: room_id,
|
id: room_id,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
members,
|
members,
|
||||||
|
host_bot_id: Some(host_id),
|
||||||
last_message_at: None,
|
last_message_at: None,
|
||||||
last_preview: None,
|
last_preview: None,
|
||||||
unread_count: 0,
|
unread_count: 0,
|
||||||
|
|
@ -258,6 +277,59 @@ async fn delete_room(
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change who leads the room. This is the only room setting that exists, and it
|
||||||
|
/// is reached by one tap on a member — there is no settings page to open.
|
||||||
|
async fn update_room(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(input): Json<UpdateRoomInput>,
|
||||||
|
) -> Result<Json<Room>, ApiError> {
|
||||||
|
let actor = actor(&state).await?;
|
||||||
|
let current = room_from_id(&state, &actor, &id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({"message":"group not found"})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Some(host_bot_id) = input.host_bot_id else {
|
||||||
|
return Ok(Json(current));
|
||||||
|
};
|
||||||
|
if !current
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.any(|member| member.id == host_bot_id)
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"message":"主持人必須是群組成員"})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let updated = sqlx::query(
|
||||||
|
"UPDATE rooms SET host_bot_id=$2, updated_at=now()
|
||||||
|
WHERE id=$1 AND space_id=$3 AND user_id=$4",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&host_bot_id)
|
||||||
|
.bind(&actor.space_id)
|
||||||
|
.bind(&actor.user_id)
|
||||||
|
.execute(state.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|error| internal(error.to_string()))?;
|
||||||
|
if updated.rows_affected() == 0 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({"message":"group not found"})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
room_from_id(&state, &actor, &id)
|
||||||
|
.await
|
||||||
|
.map(|room| Json(room.unwrap_or(current)))
|
||||||
|
.map_err(|error| internal(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_sessions(
|
async fn list_sessions(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,25 @@ pub async fn send(
|
||||||
let stored_body = crate::attachments::caption_for_title(text, &decoded);
|
let stored_body = crate::attachments::caption_for_title(text, &decoded);
|
||||||
let mut stored_blocks = blocks.to_vec();
|
let mut stored_blocks = blocks.to_vec();
|
||||||
stored_blocks.extend(crate::attachments::stored_blocks(&decoded));
|
stored_blocks.extend(crate::attachments::stored_blocks(&decoded));
|
||||||
|
// Who answers is decided before the row lock: an `@name` settles it for
|
||||||
|
// free, and the one model call that sometimes stands in for it must never
|
||||||
|
// hold this thread's transaction open.
|
||||||
|
let room = crate::routing::room_for_thread(state, thread_id).await?;
|
||||||
|
// One run per chosen member — `@name` wins, then the picker's short list,
|
||||||
|
// then the host. A room message keeps this list with it so the chat can say
|
||||||
|
// who is about to talk; a direct message records nothing extra.
|
||||||
|
let mut member_ids: Vec<String> = match room.as_ref() {
|
||||||
|
Some(room) => {
|
||||||
|
crate::routing::audience_for(state, actor, room, thread_id, text)
|
||||||
|
.await
|
||||||
|
.targets
|
||||||
|
}
|
||||||
|
None => vec![bot_id.to_string()],
|
||||||
|
};
|
||||||
|
if member_ids.is_empty() {
|
||||||
|
member_ids.push(bot_id.to_string());
|
||||||
|
}
|
||||||
|
let reply_bot_ids: Option<Vec<String>> = room.map(|_| member_ids.clone());
|
||||||
let mut tx = state
|
let mut tx = state
|
||||||
.pool()
|
.pool()
|
||||||
.begin()
|
.begin()
|
||||||
|
|
@ -199,8 +218,8 @@ pub async fn send(
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,client_nonce)
|
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,client_nonce,reply_bot_ids)
|
||||||
VALUES ($1,$2,$3,'user',$4,$5,$6,$7)",
|
VALUES ($1,$2,$3,'user',$4,$5,$6,$7,$8)",
|
||||||
)
|
)
|
||||||
.bind(&message_id)
|
.bind(&message_id)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
|
|
@ -209,6 +228,7 @@ pub async fn send(
|
||||||
.bind(json!(stored_blocks))
|
.bind(json!(stored_blocks))
|
||||||
.bind(&run_id)
|
.bind(&run_id)
|
||||||
.bind(client_nonce)
|
.bind(client_nonce)
|
||||||
|
.bind(&reply_bot_ids)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
@ -220,18 +240,6 @@ pub async fn send(
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
}
|
}
|
||||||
let mut member_ids: Vec<String> = if let Some(room_id) = room_id.as_deref() {
|
|
||||||
sqlx::query_scalar("SELECT bot_id FROM room_members WHERE room_id=$1 ORDER BY created_at")
|
|
||||||
.bind(room_id)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
} else {
|
|
||||||
vec![bot_id.to_string()]
|
|
||||||
};
|
|
||||||
if member_ids.is_empty() {
|
|
||||||
member_ids.push(bot_id.to_string());
|
|
||||||
}
|
|
||||||
for (index, member_id) in member_ids.iter().enumerate() {
|
for (index, member_id) in member_ids.iter().enumerate() {
|
||||||
if merged && index == 0 {
|
if merged && index == 0 {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -280,7 +288,15 @@ pub async fn send(
|
||||||
"SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2
|
"SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2
|
||||||
AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))",
|
AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))",
|
||||||
)
|
)
|
||||||
.bind(bot_id)
|
// The question is about the bot that will actually answer, which in a
|
||||||
|
// room is whoever routing put first — not the host the message was
|
||||||
|
// addressed to.
|
||||||
|
.bind(
|
||||||
|
member_ids
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| bot_id.to_string()),
|
||||||
|
)
|
||||||
.bind(&run_id)
|
.bind(&run_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
|
|
@ -860,7 +876,7 @@ async fn execute_run(
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !room_mates.is_empty() {
|
if !room_mates.is_empty() {
|
||||||
preamble.push_str(&format!(
|
preamble.push_str(&format!(
|
||||||
"\n\nYou are {} in a group chat with: {}. Reply as yourself only. Other agents' lines are prefixed with [Name]. Do not speak for them.",
|
"\n\nYou are {} in a group chat with: {}. Reply as yourself only. Other agents' lines are prefixed with [Name]. Do not speak for them. The human names who should answer: speak when your name is on the message, stay quiet when it is not. When the piece belongs to someone else, say one short line and name them with @TheirName, which hands this one item over once.",
|
||||||
bot.name,
|
bot.name,
|
||||||
room_mates.join("、")
|
room_mates.join("、")
|
||||||
));
|
));
|
||||||
|
|
@ -2091,6 +2107,15 @@ pub(crate) async fn append_bot_message_with(
|
||||||
if body.trim().is_empty() && !has_blocks {
|
if body.trim().is_empty() && !has_blocks {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// A bot that names a member may hand this piece over: one hop only, and
|
||||||
|
// never to a member who is already working. The lookup happens before the
|
||||||
|
// transaction so the message write stays short.
|
||||||
|
let handoff = crate::routing::handoff_target(state, run_id, bot_id, body)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
tracing::warn!("room hand-off lookup for run {run_id}: {error}");
|
||||||
|
None
|
||||||
|
});
|
||||||
let mut tx = state
|
let mut tx = state
|
||||||
.pool()
|
.pool()
|
||||||
.begin()
|
.begin()
|
||||||
|
|
@ -2106,8 +2131,8 @@ pub(crate) async fn append_bot_message_with(
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let message_id = Uuid::new_v4().to_string();
|
let message_id = Uuid::new_v4().to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,speaker_bot_id)
|
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,speaker_bot_id,reply_bot_ids)
|
||||||
VALUES ($1,$2,$3,'assistant',$4,$5,$6,$7)",
|
VALUES ($1,$2,$3,'assistant',$4,$5,$6,$7,$8)",
|
||||||
)
|
)
|
||||||
.bind(&message_id)
|
.bind(&message_id)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
|
|
@ -2116,9 +2141,37 @@ pub(crate) async fn append_bot_message_with(
|
||||||
.bind(&blocks)
|
.bind(&blocks)
|
||||||
.bind(run_id)
|
.bind(run_id)
|
||||||
.bind(bot_id)
|
.bind(bot_id)
|
||||||
|
.bind(handoff.clone().map(|target| vec![target]))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
if let Some(target) = handoff.as_ref() {
|
||||||
|
let owner: Option<(String, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT r.space_id, r.user_id, b.name
|
||||||
|
FROM runs r JOIN bots b ON b.id=r.bot_id WHERE r.id=$1",
|
||||||
|
)
|
||||||
|
.bind(run_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if let Some((space_id, user_id, speaker)) = owner {
|
||||||
|
tracing::info!(run_id, target, "room hand-off queued");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,trigger,prompt,checkpoint)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,'queued','handoff',$6,$7)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(space_id)
|
||||||
|
.bind(target)
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(format!("{speaker} 在群組裡把這件事交給你:\n{body}"))
|
||||||
|
.bind(json!({"handoffFrom": run_id}))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.commit().await.map_err(|error| error.to_string())?;
|
tx.commit().await.map_err(|error| error.to_string())?;
|
||||||
let _ = crate::sessions::append_event(
|
let _ = crate::sessions::append_event(
|
||||||
state,
|
state,
|
||||||
|
|
|
||||||
|
|
@ -485,6 +485,7 @@ type MessageWithSpeakerRow = (
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Value,
|
||||||
);
|
);
|
||||||
|
|
||||||
pub async fn messages_for_session(
|
pub async fn messages_for_session(
|
||||||
|
|
@ -494,7 +495,16 @@ pub async fn messages_for_session(
|
||||||
) -> Result<Vec<SessionMessage>, ApiError> {
|
) -> Result<Vec<SessionMessage>, ApiError> {
|
||||||
let rows: Vec<MessageWithSpeakerRow> = sqlx::query_as(
|
let rows: Vec<MessageWithSpeakerRow> = sqlx::query_as(
|
||||||
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
|
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
|
||||||
m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape
|
m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape,
|
||||||
|
COALESCE((
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object('id', r.id, 'name', r.name,
|
||||||
|
'avatarColor', r.avatar_color,
|
||||||
|
'avatarShape', r.avatar_shape)
|
||||||
|
ORDER BY picked.ord)
|
||||||
|
FROM unnest(m.reply_bot_ids) WITH ORDINALITY AS picked(bot_id, ord)
|
||||||
|
JOIN bots r ON r.id = picked.bot_id
|
||||||
|
), '[]'::jsonb) AS reply_bots
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN threads t ON t.id=m.thread_id
|
JOIN threads t ON t.id=m.thread_id
|
||||||
LEFT JOIN bots b ON b.id=m.speaker_bot_id
|
LEFT JOIN bots b ON b.id=m.speaker_bot_id
|
||||||
|
|
@ -523,6 +533,8 @@ pub async fn messages_for_session(
|
||||||
speaker_name: row.10,
|
speaker_name: row.10,
|
||||||
speaker_color: row.11,
|
speaker_color: row.11,
|
||||||
speaker_shape: row.12,
|
speaker_shape: row.12,
|
||||||
|
// A malformed array must not take the whole transcript with it.
|
||||||
|
reply_bots: serde_json::from_value(row.13).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ pub struct Room {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub members: Vec<RoomMember>,
|
pub members: Vec<RoomMember>,
|
||||||
|
/// The member that answers what nobody was named for. `None` means "the
|
||||||
|
/// first member", which is what rooms used before this column resolve to.
|
||||||
|
pub host_bot_id: Option<String>,
|
||||||
pub last_message_at: Option<DateTime<Utc>>,
|
pub last_message_at: Option<DateTime<Utc>>,
|
||||||
pub last_preview: Option<String>,
|
pub last_preview: Option<String>,
|
||||||
pub unread_count: i64,
|
pub unread_count: i64,
|
||||||
|
|
@ -26,6 +29,18 @@ pub struct Room {
|
||||||
pub struct CreateRoomInput {
|
pub struct CreateRoomInput {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub member_ids: Vec<String>,
|
pub member_ids: Vec<String>,
|
||||||
|
/// Optional from the first version: leaving it out makes the first member
|
||||||
|
/// the host, which is what a group needs by default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub host_bot_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateRoomInput {
|
||||||
|
/// Must be one of the room's members.
|
||||||
|
#[serde(default)]
|
||||||
|
pub host_bot_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::RoomMember;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
|
|
@ -46,6 +48,10 @@ pub struct SessionMessage {
|
||||||
pub speaker_name: Option<String>,
|
pub speaker_name: Option<String>,
|
||||||
pub speaker_color: Option<String>,
|
pub speaker_color: Option<String>,
|
||||||
pub speaker_shape: Option<String>,
|
pub speaker_shape: Option<String>,
|
||||||
|
/// Who the group decided should answer this message, in order. Empty for a
|
||||||
|
/// one-to-one conversation and for messages written before routing existed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reply_bots: Vec<RoomMember>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# Cua adapter timings (2026-09-07)
|
|
||||||
|
|
||||||
Measured on Apple Silicon, `lazyboy/computer:local`, Cua Driver 0.23.2, inside `make cua-smoke` (`--repeat 10`). These are LazyBoy `controld` HTTP calls, not raw `cua-driver` CLI.
|
|
||||||
|
|
||||||
| Call | n | median ms | max ms |
|
|
||||||
| --- | ---: | ---: | ---: |
|
|
||||||
| `GET /controller/health` | 1 | 11 | 11 |
|
|
||||||
| `POST /observe` | 30 | 101 | 125 |
|
|
||||||
| `POST /act` | 40 | 403 | 744 |
|
|
||||||
| `POST /browser` snapshot | 10 | 48 | 51 |
|
|
||||||
| `POST /browser` click | 20 | 332 | 337 |
|
|
||||||
| `POST /browser` type | 10 | 391 | 399 |
|
|
||||||
| `POST /browser` navigate | 10 | 927 | 941 |
|
|
||||||
|
|
||||||
`/act` includes native click, batched setvalue+click, focus, and one Cua `drag`. Navigate includes the 800 ms settle in the adapter.
|
|
||||||
|
|
||||||
There is no legacy control-plane comparison in this run. Do not treat these numbers as a ship gate against CDP/AT-SPI.
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
# Cua Driver compatibility (LazyBoy desktop)
|
|
||||||
|
|
||||||
> 歷史紀錄:本文描述 2026-09-07 的雙後端驗證,已非現況。目前只保留 Cua;操作方式見 [operations.md](operations.md),最新驗證狀態見 [cua-migration-progress.md](cua-migration-progress.md)。
|
|
||||||
|
|
||||||
This report answers one question, from a real `make cua-smoke` run on 2026-09-07:
|
|
||||||
|
|
||||||
> Can Cua Driver reliably control the existing LazyBoy XFCE + Xvfb desktop container?
|
|
||||||
|
|
||||||
**Yes, as an opt-in backend.** A disposable `lazyboy/computer:local` desktop on 2026-09-07 (linux/arm64, Cua Driver 0.23.2) passed raw Driver smoke 10/10, the LazyBoy adapter 10/10, dual-display isolation, and Chromium cookie persistence across `docker pause` and `docker restart`. Production still defaults to `legacy`. See [cua-review.md](cua-review.md) and [cua-benchmark.md](cua-benchmark.md).
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
- Image: `lazyboy/computer:local` (`image/computer/Dockerfile`)
|
|
||||||
- Distro: Debian bookworm; Driver binary follows `TARGETARCH` (`linux-arm64` or `linux-x86_64`)
|
|
||||||
- Display: Xvfb `DISPLAY=:1` at 1280×800, XFCE (`xfwm4` compositor off, `xfce4-panel`, `xfdesktop`)
|
|
||||||
- Accessibility: AT-SPI 2 per screen (`at-spi-bus-launcher` + `at-spi2-registryd`)
|
|
||||||
- Browser: Debian `chromium` via `lazyboy-browser` (persistent profile, `--remote-debugging-port=9221+display`, `--force-renderer-accessibility`, `--lang=zh-TW`)
|
|
||||||
- Cua Driver: **0.23.2** (`cua-driver-rs-v0.23.2`; linux-arm64 SHA256 `be22768a207796a4bc1de50c52f32f9ef680b5e86e58c059e02eec2caba2e7bb`, linux-x86_64 SHA256 `01bf8339ec129cc00f4b4b2c6056ef1a7c5b52df39ff83ad17c9b16818aec500`)
|
|
||||||
- Install path: `/usr/local/lib/cua-driver` + `/usr/local/bin/cua-driver` (not under the persisted `/home/lazyboy` bind)
|
|
||||||
- Daemon: `cua-driver serve --grant existing-profile --socket /tmp/lazyboy/cua.sock --no-overlay` on the primary display only
|
|
||||||
- Telemetry: disabled
|
|
||||||
- How to reproduce: `make cua-smoke` (artifacts in `/tmp/lazyboy-cua-smoke-last/`)
|
|
||||||
|
|
||||||
## Doctor
|
|
||||||
|
|
||||||
`cua-driver doctor --json` exit 0, `ok: true`.
|
|
||||||
|
|
||||||
| Probe | Status | Note |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| binary | ok | `cua-driver 0.23.2 (x86_64-linux)` |
|
|
||||||
| install dir | ok | `/usr/local/lib/cua-driver/cua-driver` |
|
|
||||||
| telemetry | ok | disabled |
|
|
||||||
| display server | ok | X11 `DISPLAY=:1` |
|
|
||||||
| X11 connection | ok | connected, visible top-level windows |
|
|
||||||
| AT-SPI | **warn** | CLI `doctor` (docker exec) does not always see the XFCE session bus. The **daemon** started from `lazyboy-screen` does: native `get_window_state` + AT-SPI click/type worked 10/10. |
|
|
||||||
|
|
||||||
`cua-driver status --socket /tmp/lazyboy/cua.sock`: daemon running, permission mode `standard`. Unix socket rejects uid 0; smoke and future controld calls must run as uid 1000 (`lazyboy`).
|
|
||||||
|
|
||||||
## Results (10 consecutive iterations)
|
|
||||||
|
|
||||||
Independent application state, not Cua `"ok"`:
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
| --- | --- |
|
|
||||||
| `cua-driver --version` | `cua-driver 0.23.2` |
|
|
||||||
| screenshot (`get_desktop_state`) | 10/10 PNG of the XFCE desktop |
|
|
||||||
| window / accessibility observation | 10/10 `list_windows` + GTK `get_window_state` |
|
|
||||||
| native click | 10/10 GTK `Smoke Click` wrote `/tmp/lazyboy/cua-smoke-clicked` |
|
|
||||||
| native type | 10/10 GTK entry + `Smoke Save` wrote `hello-cua` |
|
|
||||||
| Chromium attach (existing window/profile) | 10/10 `browser_prepare` `attached_existing_profile` |
|
|
||||||
| browser semantic click / type | 10/10 local `http://127.0.0.1:8765/cua-smoke.html`; DOM became `clicked-ok` then `typed:hello-cua` |
|
|
||||||
| noVNC `:6080` still up | 10/10 |
|
|
||||||
| leftover Cua processes | none (only `cua-driver serve`) |
|
|
||||||
|
|
||||||
Same Chromium **pid 334** / **window_id 29360131** across all ten iterations. `browser_prepare` side effects were all false: no isolated profile, no copy, no restart, no extra remote-debugging toggle (LazyBoy already exposes loopback CDP).
|
|
||||||
|
|
||||||
Element refs are snapshot-scoped (`p1:1`, `p4:1`, … `p28:1`). Reusing an old ref would be wrong; the smoke re-snapshots every action.
|
|
||||||
|
|
||||||
## Relevant tools (0.23.2 `list-tools`)
|
|
||||||
|
|
||||||
Observation / native input used by `controld`: `get_desktop_state`, `list_windows`,
|
|
||||||
`get_window_state`, `click`, `type_text`, `press_key`, `hotkey`, `scroll`, `drag`,
|
|
||||||
`move_cursor`, `set_value`, `bring_to_front`, `get_cursor_position`, `get_screen_size`.
|
|
||||||
|
|
||||||
Browser (attach only): `browser_prepare` (`strategy.kind=existing_profile`,
|
|
||||||
`allow_launch=false`), `get_browser_state` (`semantic_v2`), `browser_navigate`
|
|
||||||
(http/https/about only), `browser_click`, `browser_type`.
|
|
||||||
|
|
||||||
Lifecycle / diagnostics: `start_session`, `end_session`, `health_report`. Skill
|
|
||||||
teaching also uses `start_recording` / `stop_recording`.
|
|
||||||
|
|
||||||
Not used: `mouse_button_down`, `mouse_button_up`, `mouse_drag` (held-button
|
|
||||||
background X11 tools). LazyBoy's action DSL has no partial-pointer state, so
|
|
||||||
`Pointer{Down}` / `Pointer{Up}` translate to `ControlError::Unsupported` instead
|
|
||||||
of a half-pressed button nobody releases. Also unused: isolated `launch_app`
|
|
||||||
browsers, Wayland helpers, and the deprecated `get_session_state` /
|
|
||||||
`escalate_session` aliases. None of these names may be exposed to the LLM.
|
|
||||||
|
|
||||||
## Driver contract rules (enforced in `crates/control/src/cua`)
|
|
||||||
|
|
||||||
Each of these was confirmed against a real 0.23.2 daemon, and each one fails
|
|
||||||
silently (exit 0) if violated:
|
|
||||||
|
|
||||||
1. **Repeat the `session` label on every call.** `CuaClient::call` injects
|
|
||||||
`lazyboy-<display>` unless the caller already set one. Without it each CLI
|
|
||||||
process gets an ephemeral `cli-<uuid>` session, so trajectory turns,
|
|
||||||
snapshots, and browser binds never line up, and every call also emits a bogus
|
|
||||||
`end_session` turn.
|
|
||||||
2. **Never send `target: {kind: "desktop", display_id: "primary"}`.** The Linux
|
|
||||||
driver rejects it with `invalid_action_target` (exit 0). Omit `target` to use
|
|
||||||
the global input route.
|
|
||||||
3. **Desktop `scroll` needs a point.** With `scope: "desktop"`, `x`/`y` are
|
|
||||||
required (`missing field x`); `dispatch` aims at the pointer and falls back to
|
|
||||||
the screen centre. `amount` is clamped to the schema range `1..=50`.
|
|
||||||
4. **Only a JSON object proves the tool ran.** A refusal or prose banner that
|
|
||||||
arrives with exit 0 is a failure (`decode_stdout`), never an empty success.
|
|
||||||
5. **One escalation retry.** `background_unavailable` carries
|
|
||||||
`escalation.recommended`; the client retries once with that `delivery_mode`
|
|
||||||
and never loops.
|
|
||||||
6. **`get_window_state` can be degraded.** AT-SPI intermittently answers with
|
|
||||||
`degraded: true` and a root-only tree. Such windows are skipped and the
|
|
||||||
observation reports `native_observation_complete: false` rather than failing
|
|
||||||
the whole `observe`.
|
|
||||||
7. **Nothing is validated for you.** The Linux schemas declare
|
|
||||||
`additionalProperties: false` and numeric bounds, but 0.23.2 accepts unknown
|
|
||||||
keys and out-of-range values anyway (a bogus key on `list_windows` and
|
|
||||||
`scroll amount: 0` both return exit 0 with `effect: unverifiable`), so the
|
|
||||||
bounds in `docs/cua-schemas/0.23.2/` are enforced here, by the client.
|
|
||||||
`session` is accepted by every tool, including the ones whose own schema
|
|
||||||
omits it (`list_windows`, `bring_to_front`, `health_report`,
|
|
||||||
`start_recording`), which is what lets rule 1 be applied uniformly.
|
|
||||||
8. **Socket peer uid.** `/tmp/lazyboy/cua*.sock` rejects uid 0; `controld` runs as
|
|
||||||
the desktop user (uid 1000).
|
|
||||||
|
|
||||||
## Integration notes for the next PR
|
|
||||||
|
|
||||||
- Call Cua as uid 1000 via `cua-driver call --socket /tmp/lazyboy/cua.sock`. Root is rejected (`reject Unix peer uid 0 for runtime owned by uid 1000`).
|
|
||||||
- `get_browser_state` on a live LazyBoy Chromium first returns `browser_consent_required` / `consumer_profile_endpoint_requires_grant`. Then `browser_prepare` with `existing_profile` attaches. Serve must keep `--grant existing-profile`. Never `allow_launch`.
|
|
||||||
- Linux Chromium trusted CDP pointer is unavailable; smoke used `browser_click` `input_route=dom_event` and verified the DOM. Production adapter should prefer that route on this platform and treat `browser_input_trust_unavailable` as classified, not a silent xdotool fallback.
|
|
||||||
- Extra Team screens are extra Xvfb `DISPLAY`s. This POC only runs a daemon on `:1`. Later: one socket per slot.
|
|
||||||
- `browser_navigate` refuses `file://`; local fixtures need `http://127.0.0.1`.
|
|
||||||
- `get_window_state` on Linux is `additionalProperties: false` — do not send macOS-only fields such as `include_accessibility_tree`.
|
|
||||||
|
|
||||||
## Known limits
|
|
||||||
|
|
||||||
- Doctor AT-SPI warn from a non-desktop D-Bus is not a daemon failure.
|
|
||||||
- Overlay warnings (`X11 channel rejected command`) appeared in the daemon log with `--no-overlay`; they did not block actions.
|
|
||||||
- Debian Chromium + zh-TW UI: existing-profile attach worked because CDP was already open, so Cua did not need the English setup-checkbox path.
|
|
||||||
- Multi-screen, pause/resume, takeover, and skill recording were **not** in this POC; later PRs added controller routing, browser attach, takeover re-observe, and dual-source skill recording.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Cua Driver 0.23.2 **can** control the existing LazyBoy XFCE + Xvfb container: screenshot, window/AT-SPI observation, native click/type, and Chromium semantic click/type, 10/10, without replacing the browser profile or breaking noVNC.
|
|
||||||
|
|
||||||
`ComputerController` is in place. Production defaults to `legacy` pending the full acceptance suite and benchmark. Set `LAZYBOY_COMPUTER_DRIVER=cua` only for explicit testing. Set `LAZYBOY_COMPUTER_DRIVER=legacy` on the supervisor (passed into each desktop container) to roll back to CDP/AT-SPI/xdotool. Recreate desktop containers after changing the flag.
|
|
||||||
|
|
||||||
With `cua`: `POST /observe`, `POST /act`, and `POST /browser` go through Cua Driver. The Agent-facing `browser` schema is unchanged (`snapshot` / `click` / `type` / `press` / `navigate` / `wait`); Cua attaches with `existing_profile` and maps `semantic_v2` refs (`pN:M`) onto the existing element list. After human takeover ends, the run forces a fresh `computer_observe` and drops pre-handoff ids/refs. Skill teaching starts Cua `start_recording` (no video) plus the existing CDP DOM recorder so a human noVNC demo still yields semantic click/type/navigate events; Cua trajectory turns are ingested as extra evidence and password-labelled typing is masked. `use_saved_login` still fills via CDP stdin so passwords never appear on argv. `cdp.py` / AT-SPI remain for login fill, human browser recording, and the `legacy` rollback.
|
|
||||||
|
|
||||||
|
|
||||||
## Review of the current checkout (2026-09-07)
|
|
||||||
|
|
||||||
The locally tagged `lazyboy/computer:local` image now installs Cua Driver 0.23.2
|
|
||||||
for the build architecture (`cua-driver 0.23.2` on linux/arm64 in this run).
|
|
||||||
`make cua-smoke` is the acceptance entry: raw Driver smoke, adapter E2E,
|
|
||||||
isolation, and pause/restart persistence. Production defaults remain `legacy`.
|
|
||||||
See [the migration audit](cua-review.md).
|
|
||||||
|
|
||||||
One upstream caveat about that persistence claim: Chromium writes its cookie
|
|
||||||
database on a ~30 s timer and does not flush on `SIGTERM`. A profile survives
|
|
||||||
`docker pause` / `docker restart` once that write has landed; stopping a desktop
|
|
||||||
seconds after a login can still lose the cookie, and no LazyBoy code controls
|
|
||||||
the timer. `scripts/cua-smoke-test.sh` waits for the fixture cookie to reach the
|
|
||||||
profile before it restarts, so the check measures profile persistence instead of
|
|
||||||
the flush timer.
|
|
||||||
|
|
||||||
Image architectures are capped at `linux/amd64` and `linux/arm64` by upstream
|
|
||||||
binaries: the Cua Driver ships only `linux-x86_64` / `linux-arm64` and ONNX
|
|
||||||
Runtime only `linux-x64` / `linux-aarch64`. See
|
|
||||||
[development.md](development.md#映像與-cpu-架構).
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# Cua-only migration verification
|
|
||||||
|
|
||||||
Verified locally on 2026-09-08 (Linux arm64, Cua Driver 0.23.2). Source changes and local acceptance are complete. Production deployment is not part of this verification.
|
|
||||||
|
|
||||||
The subsequent named-cursor feature, Chinese badge font, and its separate image
|
|
||||||
verification are documented in [Agent cursor on the shared desktop](agent-cursor.md).
|
|
||||||
|
|
||||||
## Requirements and evidence
|
|
||||||
|
|
||||||
| Requirement | Implementation | Verification |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Remove the old control implementation | Cua is the only ComputerDriver. Deleted LegacyController, direct CDP/AT-SPI Python controllers, clipboard.py, process helpers and the tmux shell. Removed xdotool/xclip packages. | Source audit; old backend names rejected; final running image contains neither binary. |
|
|
||||||
| See what Cua is doing | Actions foreground the existing browser/native window on the bot's shared display. Shell/file tools use visible named terminals; clipboard operations use a visible GTK editor. | Native/browser/noVNC smoke; terminal and clipboard integration; actual VNC demonstration. |
|
|
||||||
| All computer actions through Cua | Browser, native pointer/key/ref actions, launch/focus, shell/file tools, clipboard and saved-login use the Cua controller. Removed blanket browser-pixel blocking so canvas/unsupported controls can use fresh screenshot coordinates through Cua. | Adapter, terminal, clipboard, login and dual-display tests; API tool-path audit found no hidden shell execution in agent computer/file tools. |
|
|
||||||
| Time on every conversation record | Every persisted message renders MessageTime, including attachment/chip messages. Date/time includes seconds, ISO datetime and full local-time tooltip. | Real user and assistant error messages inspected at desktop and 500px width; frontend tests/typecheck/build. |
|
|
||||||
| Faster/reliable connections | Reuse browser bindings; start VNC before Cua; record the daemon PID; do not inherit startup locks; skip repeated network attachment; reject missing networks before connect and fall back to host ports; recover expired Cua sessions for reads without replaying mutations. | Warm ensure reuses one daemon with an available lock; missing-network fallback leaves no stale attachment; session-expiry observation/browser recovery and mutation refusal pass. |
|
|
||||||
|
|
||||||
Infrastructure provisioning/storage/database calls and independent connected-service MCP facilities remain. They are not alternate desktop controllers. Computer, browser, terminal and agent workspace-file interactions use Cua.
|
|
||||||
|
|
||||||
## Acceptance results
|
|
||||||
|
|
||||||
Final desktop image `lazyboy/computer:cua-work`:
|
|
||||||
`sha256:4ad46205023694a26ff06f9e969d7c7e0ac43b98cae97a4483d2ba3379462117`
|
|
||||||
|
|
||||||
- `cargo test --workspace`: 204 passed; the environment-dependent login integration is ignored by default and separately passed.
|
|
||||||
- `cargo clippy --workspace --all-targets -- -D warnings`: passed.
|
|
||||||
- `cargo fmt --all --check` and `git diff --check`: passed.
|
|
||||||
- Frontend: 44 tests passed; TypeScript/Vite build passed.
|
|
||||||
- `scripts/cua-smoke-test.sh --docker --repeat 1 --image lazyboy/computer:cua-work`: passed on the final image. Covers native/browser/noVNC, terminal persistence/Unicode/interrupt/reset, clipboard Unicode/multiline/copy, two-display isolation, session expiry, and browser-cookie persistence across pause/restart.
|
|
||||||
- `COMPUTER_IMAGE=lazyboy/computer:cua-work scripts/cua-login-test.sh`: passed. Uses a trusted local HTTPS fixture in a disposable container and the production field-filling function, checks both exact values and verifies no submission.
|
|
||||||
- Manual VNC demonstration switched the target desktop from Chromium to its terminal. Teaching retained 2 window events and 3 screenshots, including the final frame. The target's recording was driven by human-style VNC input, not target-side Cua action calls.
|
|
||||||
|
|
||||||
Local measurements are samples, not production benchmarks: boot request 2.358s; warm screen URL request 0.100s; missing-network fallback 0.086s with unchanged Docker network attachments. A full container replacement/restart took 10.419s, including Docker shutdown.
|
|
||||||
|
|
||||||
## Driver compatibility fixes
|
|
||||||
|
|
||||||
- A zero CLI exit code is insufficient: `effect: refused` is treated as failure along with `status: refused`.
|
|
||||||
- Email input can refuse Cua browser typing. Its fallback resolves one uniquely labelled visible native web entry through Cua, clicks the fresh observed bounds, selects all and pastes through Cua. Duplicate labels, browser chrome and zero-size fields are rejected.
|
|
||||||
- Native type_text loses Unicode in terminals. Shell commands use ASCII Bash literals encoding UTF-8 bytes; general Unicode/multiline paste uses the Cua-operated clipboard editor. Zsh confirms multiline bracketed paste with another Enter.
|
|
||||||
- The GTK helper exposes exact clipboard text through its accessibility label because the pinned driver does not return GTK entry values.
|
|
||||||
- Expired driver sessions are revived for observations. Mutations rejected at expiry are not replayed. Expired browser bindings are classified as stale and re-bound for read requests.
|
|
||||||
- Browser semantic clicks use `dom_event`; callers still inspect results. Unsupported controls can be operated with Cua coordinates from a fresh screenshot.
|
|
||||||
|
|
||||||
## Teaching and environment limits
|
|
||||||
|
|
||||||
Cua trajectories record driver invocations, not raw human VNC clicks/keys. Teaching retains window changes and visual keyframes. The start message and model prompt describe that accurately and require review of missing/ambiguous steps. Model failure no longer claims the skill was learned. The local environment has no real model key, so model-generated playbook quality was not tested; recording persistence and missing-key handling were verified.
|
|
||||||
|
|
||||||
Local API: `http://127.0.0.1:3111`; supervisor7191. The test bot `1ae00840-ceaf-4197-957d-661df677b015` is running the final image with one Cua daemon. Generated test credentials are in the local .env; no real provider credentials were used. API login sessions are in-memory, so restarting the API requires signing in again (existing behavior).
|
|
||||||
|
|
||||||
The disposable Postgres test database uses tmpfs on15434; the pre-existing compose database volume was left intact. The separate old `lazyboy-cua-verify` container is a UI-test viewer, not the final target desktop. Temporary smoke/login containers are removed by their scripts.
|
|
||||||
|
|
||||||
## Local evidence files
|
|
||||||
|
|
||||||
- `/tmp/lazyboy-workspace-tests.log`, `/tmp/lazyboy-workspace-clippy.log`
|
|
||||||
- `/tmp/lazyboy-acceptance-smoke.log`, `/tmp/lazyboy-acceptance-login.log`
|
|
||||||
- `/tmp/lazyboy-network-fallback-test.log`, `/tmp/lazyboy-session-recovery-test.log`
|
|
||||||
- `/tmp/lazyboy-chat-time.png`, `/tmp/lazyboy-chat-mobile.png`, `/tmp/teach-vnc.png`
|
|
||||||
- `/tmp/lazyboy-web-build-final.log`, `/tmp/lazyboy-final-frontend-tests.log`
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# Cua 遷移檢查(2026-09-07)
|
|
||||||
|
|
||||||
> 歷史紀錄:本文描述 2026-09-07 的雙後端驗證,已非現況。目前只保留 Cua;操作方式見 [operations.md](operations.md),最新驗證狀態見 [cua-migration-progress.md](cua-migration-progress.md)。
|
|
||||||
|
|
||||||
結論:`a.md` Phase 1 規格尚未全部勾完,但 **opt-in Cua 已可在現有 XFCE + Xvfb 桌面容器使用**。生產預設仍是 `legacy`。
|
|
||||||
|
|
||||||
本次在 Apple Silicon(linux/arm64)上以 `lazyboy/computer:local` + Cua Driver **0.23.2** 重跑隔離桌面驗收。
|
|
||||||
|
|
||||||
## 現況
|
|
||||||
|
|
||||||
| 規格 | 狀態 |
|
|
||||||
| --- | --- |
|
|
||||||
| 雙後端、Agent schema 不變 | 完成。`LAZYBOY_COMPUTER_DRIVER=legacy`(預設)或 `cua`。 |
|
|
||||||
| Docker 安裝 | 完成。Dockerfile 依 `TARGETARCH` 安裝 linux-arm64 / linux-x86_64,checksum 固定。 |
|
|
||||||
| `computer_observe` | 完成。截圖 + native AT-SPI 元素(`kind=a11y`、snapshot-scoped `cua:…` handle)+ 視窗列表。 |
|
|
||||||
| `computer_act` | 完成。pointer / type / key / scroll / focus / 單次 `drag`;native ref 走 Cua `click` / `set_value`。不支援的動作明確 `Unsupported`,不再偷偷回退 legacy。 |
|
|
||||||
| Browser | 完成。`existing_profile` attach、`semantic_v2` refs、navigate 限 http/https/about。 |
|
|
||||||
| 多螢幕隔離 | 完成。display `:1` 的 handle 送到 `:2` 會被拒絕;各自點擊只改自己的測試程式。 |
|
|
||||||
| pause / restart 後 Chromium cookie | 完成。adapter `--check-persistence` 在 `docker pause` 與 `docker restart` 後都通過。 |
|
|
||||||
| noVNC | smoke 確認 `:6080` 仍可連;完整 human takeover 端到端未另開測試。 |
|
|
||||||
| 示範錄製 | Cua `start_recording` + 既有 CDP recorder 仍在;未做真人 noVNC 示範驗收。 |
|
|
||||||
| Benchmark 文件 | 見 [cua-benchmark.md](cua-benchmark.md)。尚未對 legacy 做對照。 |
|
|
||||||
| 預設切到 Cua | **未做。** 完整 DoD(takeover、錄製、生產 metrics)未過前維持 legacy。 |
|
|
||||||
|
|
||||||
## 這輪修正
|
|
||||||
|
|
||||||
- Native 同一批 `computer_act` 共用一份觀察快照;先前每個動作都把 handle map 拿掉,導致 `wait` 後面的 ref 或連續兩個 ref 被當成過期。
|
|
||||||
- 拒絕的過期 ref 不再清掉該螢幕上其他仍有效的 handle。
|
|
||||||
- JSON `code != ok` 即使行程成功碼為 0 也當失敗(避免 drag 誤報成功)。
|
|
||||||
- CLI JSON 改走 stdin,避免輸入文字出現在 argv。
|
|
||||||
- 拖曳改打最小包含該點的視窗(避免點到覆蓋其上的 Chromium),focus 優先精確標題(避免 `LazyBoy Cua Smoke - Chromium` 搶走 GTK 視窗)。
|
|
||||||
- GTK 測資把 drawing area 座標轉成螢幕座標。
|
|
||||||
- Cua 選到但 binary 不在或 daemon 起不來會明確失敗。
|
|
||||||
|
|
||||||
## 驗證(本機 2026-09-07)
|
|
||||||
|
|
||||||
- `cargo test --locked -p lazyboy-control`:84 通過。
|
|
||||||
- `cargo check --locked -p lazyboy-api -p lazyboy-sandbox -p lazyboy-controld`:通過。
|
|
||||||
- `make cua-smoke`(`--repeat 10`):
|
|
||||||
- 原始 Driver smoke **10/10**(截圖、視窗、native click/type、Chromium attach、noVNC)
|
|
||||||
- LazyBoy adapter **10/10**(GTK click / 中文 setvalue、過期 handle 拒絕、批次 native、drag、Chromium 表單)
|
|
||||||
- 雙 display 隔離通過
|
|
||||||
- pause/unpause 與 `docker restart` 後 cookie 仍在
|
|
||||||
|
|
||||||
## 如何啟用
|
|
||||||
|
|
||||||
預設不要改。要在本機明確跑 Cua:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make cua-smoke
|
|
||||||
# 或
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.cua.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
overlay 把 supervisor 的 `LAZYBOY_COMPUTER_DRIVER` 設成 `cua`,桌面映像標成 `lazyboy/computer:cua`,不會覆寫預設的 `lazyboy/computer:local` legacy 映像。改 flag 後必須重建桌面容器。
|
|
||||||
|
|
@ -38,7 +38,6 @@ python3 tests/log-rotation.test.py
|
||||||
|
|
||||||
# Cua Driver 能否控制現有 XFCE + Xvfb 桌面(會建 computer image)
|
# Cua Driver 能否控制現有 XFCE + Xvfb 桌面(會建 computer image)
|
||||||
make cua-smoke
|
make cua-smoke
|
||||||
# 結果摘要見 docs/cua-compatibility.md、docs/cua-review.md
|
|
||||||
# 生產路徑只使用 Cua;更新後請重建 supervisor 與桌面映像。
|
# 生產路徑只使用 Cua;更新後請重建 supervisor 與桌面映像。
|
||||||
|
|
||||||
# Python 整合測試用 docker compose exec 連進 Postgres,自己建一次性資料庫後清掉
|
# Python 整合測試用 docker compose exec 連進 Postgres,自己建一次性資料庫後清掉
|
||||||
|
|
@ -105,6 +104,31 @@ builder 並註冊 binfmt;主機本身的架構會跳過註冊,否則在特
|
||||||
不推送 registry 時輸出 OCI archive(`nerdctl load -i <檔>` 可載入),因為 Docker 的
|
不推送 registry 時輸出 OCI archive(`nerdctl load -i <檔>` 可載入),因為 Docker 的
|
||||||
`docker` driver 無法匯出 manifest list。
|
`docker` driver 無法匯出 manifest list。
|
||||||
|
|
||||||
|
### README 首頁圖
|
||||||
|
|
||||||
|
首頁圖不再手刻假介面:先用 `scripts/capture-hero.mjs` 拍**正在跑的真實介面**,再由
|
||||||
|
`docs/hero.html` 套上品牌框輸出 `docs/readme-hero.png`。兩段都只要有任一 Chromium
|
||||||
|
(`CHROME=` 指定,否則依序找 PATH 上的 chromium/Chrome 與 Playwright 下載的 chrome)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 對運行中的 stack 擷取真實畫面(登入 token 讀 .env,可用 LB_TOKEN/LB_URL 覆寫)
|
||||||
|
# 群組視圖的右欄是機器人自己的瀏覽器,進圖前先用 --hide 遮掉,再補一張中立的上去;
|
||||||
|
# --anchor 會印出被遮區塊在圖上的位置,hero.html 的圖層座標就是從這裡來的。
|
||||||
|
node scripts/capture-hero.mjs --out docs/hero/agent.png
|
||||||
|
node scripts/capture-hero.mjs --out docs/hero/room.png --pick 測試聊天 \
|
||||||
|
--hide .side-card --anchor .side-card
|
||||||
|
node scripts/capture-hero.mjs --out docs/hero/desktop.png --pick 阿狗 --selector .side-card
|
||||||
|
|
||||||
|
# 2. 套框輸出 README 用的圖,SHOT= 決定用哪張(預設 room)
|
||||||
|
scripts/render-readme-hero.sh
|
||||||
|
SHOT=agent scripts/render-readme-hero.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`capture-hero.mjs` 直接講 CDP,沒有 npm 相依。預設擷取 1480x650(2 倍圖),正好對應
|
||||||
|
`hero.html` 的 1184x520 視窗,不會裁到圖。noVNC 是即時串流,`--settle`(預設 6 秒)是留給
|
||||||
|
桌面畫出內容的時間。**發布前務必逐張檢查**:機器人瀏覽器裡留著的帳號、網址與貼文會一起進
|
||||||
|
圖,`--hide` 的 selector 沒命中時擷取會直接失敗,不要繞過這個檢查把圖放進 repo。
|
||||||
|
|
||||||
### 專案結構
|
### 專案結構
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -122,7 +146,8 @@ LazyBoy/
|
||||||
├── migrations/ SQLx PostgreSQL migrations
|
├── migrations/ SQLx PostgreSQL migrations
|
||||||
├── tests/ Rust 以外的契約與回歸測試
|
├── tests/ Rust 以外的契約與回歸測試
|
||||||
├── scripts/ 環境初始化與執行工具
|
├── scripts/ 環境初始化與執行工具
|
||||||
├── docs/hero.html README 首頁視覺原稿
|
├── docs/hero.html README 首頁框架(品牌+真實截圖)
|
||||||
|
├── docs/hero/ README 用的真實截圖原檔
|
||||||
├── docs/workflow.html 可互動產品流程圖
|
├── docs/workflow.html 可互動產品流程圖
|
||||||
├── docker-compose.yml 正式堆疊
|
├── docker-compose.yml 正式堆疊
|
||||||
├── clippy.toml Clippy 閾值(要在 repo 根目錄執行才讀得到)
|
├── clippy.toml Clippy 閾值(要在 repo 根目錄執行才讀得到)
|
||||||
|
|
|
||||||
459
docs/hero.html
|
|
@ -26,332 +26,49 @@
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 1280px;
|
width: 1280px;
|
||||||
height: 640px;
|
height: 760px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f3f3f5;
|
background: #f3f3f5;
|
||||||
color: #161618;
|
color: #161618;
|
||||||
font-family: Huninn, "jf open 粉圓", "PingFang TC", sans-serif;
|
font-family: Huninn, "jf open 粉圓", "PingFang TC", sans-serif;
|
||||||
}
|
}
|
||||||
.page {
|
.page { width: 1280px; height: 760px; padding: 30px 48px 0; }
|
||||||
width: 1280px;
|
.top { display: flex; align-items: center; justify-content: space-between; height: 36px; }
|
||||||
height: 640px;
|
.brand { display: flex; align-items: center; gap: 10px; font-size: 20px; letter-spacing: .02em; }
|
||||||
padding: 28px 48px 0;
|
.brand svg { width: 26px; height: 26px; }
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 20px;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
}
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 12px;
|
||||||
align-items: center;
|
border: 1px solid #e4e4e8; border-radius: 999px; background: #fff; color: #5c5c64; font-size: 12px;
|
||||||
gap: 8px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid #e4e4e8;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #fff;
|
|
||||||
color: #5c5c64;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.pill b {
|
|
||||||
color: #0f766e;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.copy {
|
|
||||||
text-align: center;
|
|
||||||
margin: 22px auto 18px;
|
|
||||||
max-width: 760px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 42px;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: .01em;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
.lead {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: #6b6b73;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
}
|
||||||
|
.pill b { color: #0f766e; font-weight: 400; }
|
||||||
|
.copy { text-align: center; margin: 20px auto 18px; max-width: 820px; }
|
||||||
|
h1 { margin: 0; font-size: 40px; font-weight: 400; letter-spacing: .01em; line-height: 1.15; }
|
||||||
|
.lead { margin: 10px 0 0; color: #6b6b73; font-size: 15px; line-height: 1.55; }
|
||||||
.window {
|
.window {
|
||||||
width: 1184px;
|
width: 1184px; height: 554px; margin: 0 auto;
|
||||||
height: 470px;
|
border-radius: 16px 16px 0 0; overflow: hidden; background: #0d0d0e;
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0d0d0e;
|
|
||||||
box-shadow: 0 24px 70px rgba(16, 16, 20, .28), 0 0 0 1px rgba(0,0,0,.08);
|
box-shadow: 0 24px 70px rgba(16, 16, 20, .28), 0 0 0 1px rgba(0,0,0,.08);
|
||||||
display: grid;
|
display: grid; grid-template-rows: 34px 1fr;
|
||||||
grid-template-rows: 36px 1fr;
|
|
||||||
}
|
}
|
||||||
.chrome {
|
.chrome {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 7px; padding: 0 14px;
|
||||||
align-items: center;
|
background: #161618; border-bottom: 1px solid #202023;
|
||||||
gap: 7px;
|
|
||||||
padding: 0 14px;
|
|
||||||
background: #161618;
|
|
||||||
border-bottom: 1px solid #202023;
|
|
||||||
}
|
}
|
||||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
.dot.r { background: #ff5f57; }
|
.dot.r { background: #ff5f57; }
|
||||||
.dot.y { background: #febc2e; }
|
.dot.y { background: #febc2e; }
|
||||||
.dot.g { background: #28c840; }
|
.dot.g { background: #28c840; }
|
||||||
.app {
|
/* Real pixels: docs/hero/*.png comes from scripts/capture-hero.mjs. */
|
||||||
display: grid;
|
/* 1184x520 matches the capture ratio (1480x650), so nothing is cropped. */
|
||||||
grid-template-columns: 228px 1fr 300px;
|
.shot { position: relative; width: 1184px; height: 520px; overflow: hidden; }
|
||||||
min-height: 0;
|
.shot img { position: absolute; display: block; }
|
||||||
color: #dfdfe2;
|
|
||||||
background: #050506;
|
|
||||||
}
|
|
||||||
.side, .chat, .desk {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.side {
|
|
||||||
background: #0b0b0c;
|
|
||||||
border-right: 1px solid #171719;
|
|
||||||
padding: 12px 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.ws {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 6px 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.ws i {
|
|
||||||
width: 26px; height: 26px;
|
|
||||||
display: grid; place-items: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #151517;
|
|
||||||
color: #85858a;
|
|
||||||
font-size: 9px;
|
|
||||||
font-style: normal;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
height: 34px;
|
|
||||||
border: 1px solid #202023;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #121214;
|
|
||||||
color: #626267;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.search svg { width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 1.7; }
|
|
||||||
.label {
|
|
||||||
padding: 8px 8px 2px;
|
|
||||||
color: #626267;
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
}
|
|
||||||
.bot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.bot.on { background: #171719; }
|
|
||||||
.bot strong { display: block; font-size: 13px; font-weight: 400; }
|
|
||||||
.bot small { display: block; color: #85858a; font-size: 11px; margin-top: 1px; }
|
|
||||||
.bot .copy { min-width: 0; flex: 1; }
|
|
||||||
.bot .copy strong, .bot .copy small {
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.time { color: #626267; font-size: 10px; align-self: start; }
|
|
||||||
.chat {
|
|
||||||
background: #0d0d0e;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 52px 1fr 118px;
|
|
||||||
}
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-bottom: 1px solid #171719;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
.grow { flex: 1; }
|
|
||||||
.tools {
|
|
||||||
display: flex; gap: 2px; padding: 3px;
|
|
||||||
border: 1px solid #29292d; border-radius: 11px; background: rgba(18,18,20,.86);
|
|
||||||
}
|
|
||||||
.tools i {
|
|
||||||
width: 26px; height: 26px; border-radius: 8px;
|
|
||||||
display: grid; place-items: center;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
.tools i.on { background: #151517; }
|
|
||||||
.tools svg { width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 1.7; }
|
|
||||||
.tools i.on svg { stroke: #f1f1f2; }
|
|
||||||
.tools i:not(.on) svg { stroke: #85858a; }
|
|
||||||
.msgs {
|
|
||||||
padding: 18px 28px 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.bubble {
|
|
||||||
max-width: 78%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 18px;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.me { align-self: flex-end; background: #f1f1ef; color: #1a1a1a; }
|
|
||||||
.them { align-self: flex-start; background: #19191c; }
|
|
||||||
.dock {
|
|
||||||
padding: 8px 22px 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.think {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.think .lbl {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.think small { color: #85858a; }
|
|
||||||
.composer {
|
|
||||||
height: 52px;
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
border: 1px solid #29292d;
|
|
||||||
background: #121214;
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
.plus, .send {
|
|
||||||
width: 34px; height: 34px; border-radius: 50%;
|
|
||||||
display: grid; place-items: center;
|
|
||||||
}
|
|
||||||
.plus { color: #85858a; font-size: 20px; }
|
|
||||||
.ph { flex: 1; color: #626267; font-size: 13px; }
|
|
||||||
.send { background: #f1f1ef; color: #1b1b1c; font-size: 14px; }
|
|
||||||
.desk {
|
|
||||||
background: #0a0a0b;
|
|
||||||
border-left: 1px solid #171719;
|
|
||||||
padding: 10px 12px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.desk-h {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
color: #f1f1f2; font-size: 13px;
|
|
||||||
}
|
|
||||||
.desk-h .dot-run {
|
|
||||||
width: 7px; height: 7px; border-radius: 50%; background: #4ecb71;
|
|
||||||
}
|
|
||||||
.desk-h small { color: #85858a; margin-left: auto; font-size: 11px; }
|
|
||||||
.preview {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid #29292d;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #101012;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 168px;
|
|
||||||
}
|
|
||||||
.desktop {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 20% 0%, #1a2430 0%, transparent 42%),
|
|
||||||
linear-gradient(180deg, #15202b, #0d1218);
|
|
||||||
}
|
|
||||||
.win {
|
|
||||||
position: absolute;
|
|
||||||
left: 14px; top: 12px; right: 14px; bottom: 28px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 10px 28px rgba(0,0,0,.35);
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 22px 28px 1fr;
|
|
||||||
}
|
|
||||||
.win-bar { background: #e8e8ea; display: flex; align-items: center; gap: 5px; padding: 0 8px; }
|
|
||||||
.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: #444; font-size: 10px;
|
|
||||||
border-bottom: 1px solid #ececf0;
|
|
||||||
}
|
|
||||||
.page-body { padding: 12px 14px; color: #222; }
|
|
||||||
.page-body h3 { margin: 0 0 6px; font-size: 13px; font-weight: 400; }
|
|
||||||
.page-body p { margin: 0; color: #666; font-size: 11px; line-height: 1.45; }
|
|
||||||
.progress {
|
|
||||||
margin-top: 10px; height: 6px; border-radius: 99px; background: #ececf0; overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress b { display: block; width: 62%; height: 100%; background: #3ec5a8; }
|
|
||||||
.next {
|
|
||||||
margin-top: 10px; height: 24px; width: 64px;
|
|
||||||
border-radius: 6px; background: #1a1a1c; color: #fff;
|
|
||||||
display: grid; place-items: center; font-size: 11px;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
position: absolute; left: 0; right: 0; bottom: 0; height: 22px;
|
|
||||||
background: #1b242e; border-top: 1px solid #2a3540;
|
|
||||||
}
|
|
||||||
.cap {
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
color: #85858a; font-size: 11px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
height: 28px; padding: 0 10px; border-radius: 8px;
|
|
||||||
border: 1px solid #29292d; color: #dfdfe2; display: grid; place-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.btn.cream { background: #f1f1ef; color: #181819; border-color: #f1f1ef; }
|
|
||||||
.actions { display: flex; justify-content: flex-end; gap: 6px; }
|
|
||||||
|
|
||||||
/* blobatar-ish faces */
|
|
||||||
.blob { width: 32px; height: 32px; flex: 0 0 32px; }
|
|
||||||
.blob.sm { width: 22px; height: 22px; flex-basis: 22px; }
|
|
||||||
.blob.md { width: 28px; height: 28px; flex-basis: 28px; }
|
|
||||||
.think-ring { position: relative; }
|
|
||||||
.think-ring:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute; inset: -5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: conic-gradient(from 40deg, transparent 0 8%, #ff4fd8 12%, #7c5cff 18%, transparent 26% 48%, #34d9ff 54%, transparent 62%);
|
|
||||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
|
|
||||||
mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<svg class="blob md" viewBox="0 0 64 64" aria-hidden="true">
|
<svg viewBox="0 0 64 64" aria-hidden="true">
|
||||||
<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"/>
|
<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="25.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/>
|
||||||
<ellipse cx="40.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/>
|
<ellipse cx="40.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/>
|
||||||
|
|
@ -360,83 +77,75 @@
|
||||||
</svg>
|
</svg>
|
||||||
LazyBoy
|
LazyBoy
|
||||||
</div>
|
</div>
|
||||||
<div class="pill"><b>Self-hosted</b> a Linux desktop per agent</div>
|
<div class="pill"><b id="pill-strong"></b> <span id="pill-rest"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="copy">
|
<div class="copy">
|
||||||
<h1>Give an agent a real computer</h1>
|
<h1 id="title"></h1>
|
||||||
<p class="lead">Open bots in the browser. They browse, run commands, and learn the flows you demonstrate.<br/>Chats and desktops stay on your host. You choose the model.</p>
|
<p class="lead" id="lead"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="window">
|
<div class="window">
|
||||||
<div class="chrome"><i class="dot r"></i><i class="dot y"></i><i class="dot g"></i></div>
|
<div class="chrome"><i class="dot r"></i><i class="dot y"></i><i class="dot g"></i></div>
|
||||||
<div class="app">
|
<div class="shot"></div>
|
||||||
<aside class="side">
|
|
||||||
<div class="ws"><i>LB</i> Local workspace</div>
|
|
||||||
<div class="search"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>Search</div>
|
|
||||||
<div class="label">Pinned</div>
|
|
||||||
<div class="bot on">
|
|
||||||
<span class="think-ring">
|
|
||||||
<svg class="blob" 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>
|
|
||||||
</span>
|
|
||||||
<div class="copy"><strong>Engineering</strong><small>Page 12, waiting for Next</small></div>
|
|
||||||
<span class="time">14:08</span>
|
|
||||||
</div>
|
|
||||||
<div class="label">Agent</div>
|
|
||||||
<div class="bot">
|
|
||||||
<svg class="blob" viewBox="0 0 64 64"><path fill="#f5a03c" d="M30 7c13-1 26 10 25 24-1 15-12 26-25 27S5 46 7 31 17 8 30 7z"/><ellipse cx="25" cy="30" rx="4" ry="4.8" fill="#1b1b22"/><ellipse cx="40" cy="30" rx="4" ry="4.8" fill="#1b1b22"/><circle cx="26.3" cy="28.3" r="1.2" fill="#f3f3f6"/><circle cx="41.3" cy="28.3" r="1.2" fill="#f3f3f6"/></svg>
|
|
||||||
<div class="copy"><strong>Morning digest</strong><small>Runs tomorrow at 9:00</small></div>
|
|
||||||
</div>
|
|
||||||
<div class="bot">
|
|
||||||
<svg class="blob" viewBox="0 0 64 64"><path fill="#6a6bf5" d="M34 6c12 2 22 12 21 25s-12 26-25 27S6 46 8 32 22 4 34 6z"/><ellipse cx="26" cy="29" rx="4.1" ry="5" fill="#f3f3f6"/><ellipse cx="41" cy="29" rx="4.1" ry="5" fill="#f3f3f6"/><circle cx="24.8" cy="27.4" r="1.2" fill="#1b1b22"/><circle cx="39.8" cy="27.4" r="1.2" fill="#1b1b22"/></svg>
|
|
||||||
<div class="copy"><strong>Support</strong><small>Remembered the reply tone</small></div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main class="chat">
|
|
||||||
<header class="topbar">
|
|
||||||
<svg class="blob sm" 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>
|
|
||||||
Engineering
|
|
||||||
<span style="color:#85858a;font-size:12px">STAR training ▾</span>
|
|
||||||
<span class="grow"></span>
|
|
||||||
<div class="tools">
|
|
||||||
<i class="on"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8M12 17v4"/></svg></i>
|
|
||||||
<i><svg viewBox="0 0 24 24"><path d="M12 3a7 7 0 0 0-4 12.7V18h8v-2.3A7 7 0 0 0 12 3z"/><path d="M9 21h6"/></svg></i>
|
|
||||||
<i><svg viewBox="0 0 24 24"><path d="M12 5v2M12 17v2M5 12h2M17 12h2M7 7l1.5 1.5M15.5 15.5L17 17M7 17l1.5-1.5M15.5 8.5L17 7"/><circle cx="12" cy="12" r="3"/></svg></i>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="msgs">
|
|
||||||
<div class="bubble me">Finish the STAR course and submit the quiz.</div>
|
|
||||||
<div class="bubble them">Computer is up. Page 12 / 24 — I’ll hit Next when the video ends. I won’t store passwords.</div>
|
|
||||||
</div>
|
|
||||||
<div class="dock">
|
|
||||||
<div class="think">
|
|
||||||
<span class="think-ring"><svg class="blob sm" 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></span>
|
|
||||||
<span class="lbl">Working…</span>
|
|
||||||
<small>click Next</small>
|
|
||||||
</div>
|
|
||||||
<div class="composer"><div class="plus">+</div><div class="ph">Message Engineering</div><div class="send">↑</div></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<aside class="desk">
|
|
||||||
<div class="desk-h">Engineering’s computer <i class="dot-run"></i> <small>Running</small></div>
|
|
||||||
<div class="preview">
|
|
||||||
<div class="desktop">
|
|
||||||
<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 · page 12</h3>
|
|
||||||
<p>Read this, then press Next. It unlocks when the countdown ends.</p>
|
|
||||||
<div class="progress"><b></b></div>
|
|
||||||
<div class="next">Next</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cap"><span>This screen</span><span>Enlarge</span></div>
|
|
||||||
<div class="actions"><div class="btn cream">Take over</div><div class="btn">Stop task</div></div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// One banner per README language: the frame copy and the screenshot are
|
||||||
|
// captured in the same language, so the English README never shows a
|
||||||
|
// Chinese screen and vice versa. ?lang=zh-TW|en.
|
||||||
|
const pages = {
|
||||||
|
en: {
|
||||||
|
pillStrong: 'Self-hosted',
|
||||||
|
pillRest: 'a Linux desktop per agent',
|
||||||
|
title: 'Give an agent a real computer',
|
||||||
|
lead: [
|
||||||
|
'Assign work by text, voice, or a quick demo, then watch its own Linux desktop do it.',
|
||||||
|
'Chats, memory, and desktops stay on your host. You choose the model.',
|
||||||
|
],
|
||||||
|
alt: 'LazyBoy group chat routing one message to a single agent, with that agent\'s live desktop on the right',
|
||||||
|
},
|
||||||
|
'zh-TW': {
|
||||||
|
pillStrong: '自架部署',
|
||||||
|
pillRest: '每個 Agent 一台 Linux 桌面',
|
||||||
|
title: '給 Agent 一台真正的電腦',
|
||||||
|
lead: [
|
||||||
|
'用文字、語音或一段示範交代任務,接著看它自己的 Linux 桌面動手做完。',
|
||||||
|
'對話、記憶與桌面都留在你的主機上,模型由你挑選。',
|
||||||
|
],
|
||||||
|
alt: 'LazyBoy 群組對話:訊息由其中一個 Agent 接手回覆,右側是它自己的即時桌面',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Real captures only, from scripts/capture-hero.mjs. The group view is two
|
||||||
|
// layers: room-<lang>.png is shot with `--hide .side-card`, which blanks the
|
||||||
|
// desktop panel (an agent's browser holds whatever its owner left open), and
|
||||||
|
// desktop-<lang>.png drops a neutral one back in at the anchor that same
|
||||||
|
// capture prints. Captures are 2x shots of a 1480px viewport, so a
|
||||||
|
// capture-space number becomes hero-space by multiplying by 1184/1480 = 0.8.
|
||||||
|
const lang = new URLSearchParams(location.search).get('lang') || 'en';
|
||||||
|
const page = pages[lang] || pages.en;
|
||||||
|
const capture = lang === 'en' ? 'en' : 'zh';
|
||||||
|
const layers = [
|
||||||
|
{ src: `./hero/room-${capture}.png`, x: 0, y: 0, w: 1184, h: 520 },
|
||||||
|
{ src: `./hero/desktop-${capture}.png`, x: 832, y: 0, w: 352, h: 520 },
|
||||||
|
];
|
||||||
|
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.title = `LazyBoy hero (${lang})`;
|
||||||
|
document.getElementById('pill-strong').textContent = page.pillStrong;
|
||||||
|
document.getElementById('pill-rest').textContent = page.pillRest;
|
||||||
|
document.getElementById('title').textContent = page.title;
|
||||||
|
document.getElementById('lead').innerHTML = page.lead.join('<br/>');
|
||||||
|
document.querySelector('.shot').append(
|
||||||
|
...layers.map((layer) => {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = layer.src;
|
||||||
|
img.alt = page.alt;
|
||||||
|
img.style.left = `${layer.x}px`;
|
||||||
|
img.style.top = `${layer.y}px`;
|
||||||
|
img.style.width = `${layer.w}px`;
|
||||||
|
img.style.height = `${layer.h}px`;
|
||||||
|
return img;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
|
@ -59,6 +59,7 @@
|
||||||
| `LAZYBOY_COMPUTER_SUDO` | 容器內免密碼 sudo;重建桌面容器後生效 | `false` |
|
| `LAZYBOY_COMPUTER_SUDO` | 容器內免密碼 sudo;重建桌面容器後生效 | `false` |
|
||||||
| `LAZYBOY_COMPUTER_DRIVER` | 只支援 `cua`。舊後端已移除;更新映像後需重建桌面容器。 | `cua` |
|
| `LAZYBOY_COMPUTER_DRIVER` | 只支援 `cua`。舊後端已移除;更新映像後需重建桌面容器。 | `cua` |
|
||||||
| `LAZYBOY_MEMORY_ENABLED` | 長期記憶 | `true` |
|
| `LAZYBOY_MEMORY_ENABLED` | 長期記憶 | `true` |
|
||||||
|
| `LAZYBOY_ROUTER_MODEL` | 群組沒被點名時挑人回覆用的模型;留空沿用空間預設 | 空間預設 |
|
||||||
|
|
||||||
完整清單與保留政策請見 [`.env.example`](../.env.example)。
|
完整清單與保留政策請見 [`.env.example`](../.env.example)。
|
||||||
|
|
||||||
|
|
@ -136,6 +137,25 @@ LAZYBOY_RUN_HARD_MINUTES=240 # 時間保險絲
|
||||||
|
|
||||||
真的很久的工作(大計畫、批量資料處理)不該塞在一個 run 裡,改用排程工作分段跑,比較容易驗證也比較省 token。
|
真的很久的工作(大計畫、批量資料處理)不該塞在一個 run 裡,改用排程工作分段跑,比較容易驗證也比較省 token。
|
||||||
|
|
||||||
|
## 群組聊天:誰說話
|
||||||
|
|
||||||
|
群組不是廣播。訊息進來的當下就決定好誰要回覆,其他人不被叫醒,也就不會各打一輪模型。
|
||||||
|
|
||||||
|
- `@名字` 是指令:只有被點名的人回。`@所有人`(`@全部`、`@all`、`@everyone`)才是全員出動,這是唯一的逃生口。
|
||||||
|
- 沒被點名時,由一個模型照成員的工作內容與簡介挑人,最多三個;只有兩個人的群組不挑,直接由主持人回答。
|
||||||
|
- 挑不到人、模型不能用、或挑超過 1.2 秒,一律由主持人接手。訊息不會默默消失——被無視比多回一句更糟。
|
||||||
|
- 回覆的人可以交接一次:它在訊息裡 `@某個成員`,系統就替對方排一個 run。交接而來的 run 不能再交接,兩個愛聊的機器人不會把群組和帳單一起撐爆。
|
||||||
|
- 主持人預設是群組第一位成員,對話頁上方點主持人名字就能換人,沒有群組設定頁要翻。
|
||||||
|
|
||||||
|
決定寫進 `messages.reply_bot_ids`(訊息旁邊會顯示「由誰回覆」「交接給誰」),並記進 api 日誌:
|
||||||
|
|
||||||
|
```text
|
||||||
|
INFO room routing room=… thread=… reason=mention targets=["01…"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`reason` 只有四種:`all`(@所有人)、`mention`(點名)、`routed`(模型挑的)、`host`(沒人接,主持人上)。
|
||||||
|
挑人用的模型是 `LAZYBOY_ROUTER_MODEL`,留空沿用空間預設;它只影響挑人這一句,回覆本身照各成員自己的模型。
|
||||||
|
|
||||||
## 容器內終端機
|
## 容器內終端機
|
||||||
|
|
||||||
Agent 透過 Cua 操作 VNC 上的真實終端機。`session` 指定持續使用的視窗;`command` 輸入命令,
|
Agent 透過 Cua 操作 VNC 上的真實終端機。`session` 指定持續使用的視窗;`command` 輸入命令,
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
|
@ -5,7 +5,8 @@
|
||||||
# Build from the repository root:
|
# Build from the repository root:
|
||||||
# docker build -f image/computer/Dockerfile -t lazyboy/computer:local .
|
# docker build -f image/computer/Dockerfile -t lazyboy/computer:local .
|
||||||
|
|
||||||
FROM rust:1-bookworm AS controld
|
# Pinned; must satisfy the workspace rust-version in Cargo.toml.
|
||||||
|
FROM rust:1.98.1-bookworm AS controld
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY .cargo .cargo
|
COPY .cargo .cargo
|
||||||
|
|
@ -43,13 +44,30 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
&& cp target/release/cua-driver /cua-driver
|
&& cp target/release/cua-driver /cua-driver
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Keep Chromium, XFCE, CJK, AT-SPI, git. Skip Debian novnc (pulls nodejs) and
|
# Keep Chromium, XFCE, CJK, AT-SPI, git. Skip Debian novnc (pulls nodejs) and
|
||||||
# fonts-noto-core (Latin is DejaVu/Liberation/huninn).
|
# fonts-noto-core (Latin is DejaVu/Liberation/huninn).
|
||||||
RUN printf '%s\n' \
|
# dpkg never unpacks docs/man/info/foreign locales; zh_TW catalogs are kept so
|
||||||
|
# XFCE menus are in Traditional Chinese. apt lists/archives live in cache
|
||||||
|
# mounts, so nothing has to be removed from the layer afterwards.
|
||||||
|
# Only Papirus (base) + Papirus-Dark icons are used; the Dark variant symlinks
|
||||||
|
# into the base set, so the other variants are dead weight (~60 MB).
|
||||||
|
RUN --mount=type=cache,id=lazyboy-apt-cache-$TARGETARCH,target=/var/cache/apt,sharing=locked \
|
||||||
|
--mount=type=cache,id=lazyboy-apt-lists-$TARGETARCH,target=/var/lib/apt,sharing=locked \
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'path-exclude=/usr/share/doc/*' \
|
||||||
|
'path-include=/usr/share/doc/*/copyright' \
|
||||||
|
'path-exclude=/usr/share/man/*' \
|
||||||
|
'path-exclude=/usr/share/info/*' \
|
||||||
|
'path-exclude=/usr/share/gtk-doc/*' \
|
||||||
|
'path-exclude=/usr/share/backgrounds/*' \
|
||||||
|
'path-exclude=/usr/share/locale/*' \
|
||||||
|
'path-include=/usr/share/locale/locale.alias' \
|
||||||
'path-include=/usr/share/locale/zh_TW/*' \
|
'path-include=/usr/share/locale/zh_TW/*' \
|
||||||
'path-include=/usr/share/locale/zh/*' \
|
'path-include=/usr/share/locale/zh/*' \
|
||||||
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-locale \
|
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-slim \
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
chromium \
|
chromium \
|
||||||
|
|
@ -65,11 +83,9 @@ RUN printf '%s\n' \
|
||||||
procps \
|
procps \
|
||||||
python3 \
|
python3 \
|
||||||
sudo \
|
sudo \
|
||||||
tmux \
|
|
||||||
gosu \
|
gosu \
|
||||||
util-linux \
|
util-linux \
|
||||||
websockify \
|
websockify \
|
||||||
wmctrl \
|
|
||||||
x11-utils \
|
x11-utils \
|
||||||
x11vnc \
|
x11vnc \
|
||||||
xdg-utils \
|
xdg-utils \
|
||||||
|
|
@ -83,6 +99,8 @@ RUN printf '%s\n' \
|
||||||
thunar \
|
thunar \
|
||||||
adwaita-icon-theme \
|
adwaita-icon-theme \
|
||||||
gnome-themes-extra \
|
gnome-themes-extra \
|
||||||
|
arc-theme \
|
||||||
|
papirus-icon-theme \
|
||||||
librsvg2-common \
|
librsvg2-common \
|
||||||
at-spi2-core \
|
at-spi2-core \
|
||||||
libatk-adaptor \
|
libatk-adaptor \
|
||||||
|
|
@ -96,33 +114,50 @@ RUN printf '%s\n' \
|
||||||
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
|
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
|
||||||
&& locale-gen \
|
&& locale-gen \
|
||||||
&& mkdir -p /usr/share/novnc \
|
&& mkdir -p /usr/share/novnc \
|
||||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /usr/share/doc/* /usr/share/man/* \
|
&& rm -rf /usr/share/i18n /usr/share/icons/ePapirus /usr/share/icons/ePapirus-Dark /usr/share/icons/Papirus-Light \
|
||||||
/usr/share/i18n /usr/share/info /usr/share/gtk-doc /usr/share/backgrounds \
|
&& test -e /usr/share/icons/Papirus-Dark/48x48/apps/utilities-terminal.svg \
|
||||||
|
&& test -e /usr/share/icons/Papirus-Dark/48x48/apps/web-browser.svg \
|
||||||
|
&& test -d /usr/share/themes/Arc-Dark/xfwm4 \
|
||||||
&& find /usr -name __pycache__ -type d -prune -exec rm -rf '{}' + \
|
&& find /usr -name __pycache__ -type d -prune -exec rm -rf '{}' + \
|
||||||
&& find /usr -name '*.py[co]' -delete
|
&& find /usr -name '*.py[co]' -delete
|
||||||
|
|
||||||
# jf open 粉圓 (system UI) + MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig).
|
# MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig). Pinned to a
|
||||||
|
# powerlevel10k-media commit; jf open 粉圓 (system UI) is the same verified
|
||||||
|
# file the Cua stage embeds, copied below.
|
||||||
|
ARG MESLO_BASE=https://raw.githubusercontent.com/romkatv/powerlevel10k-media/145eb9fbc2f42ee408dacd9b22d8e6e0e553f83d
|
||||||
RUN mkdir -p /usr/share/fonts/truetype/huninn /usr/share/fonts/truetype/meslo \
|
RUN mkdir -p /usr/share/fonts/truetype/huninn /usr/share/fonts/truetype/meslo \
|
||||||
&& curl -fsSL -o /usr/share/fonts/truetype/huninn/jf-openhuninn-2.1.ttf \
|
&& cd /usr/share/fonts/truetype/meslo \
|
||||||
https://github.com/justfont/open-huninn-font/releases/download/v2.1/jf-openhuninn-2.1.ttf \
|
&& curl -fsSL -o "MesloLGS NF Regular.ttf" "$MESLO_BASE/MesloLGS%20NF%20Regular.ttf" \
|
||||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Regular.ttf" \
|
&& curl -fsSL -o "MesloLGS NF Bold.ttf" "$MESLO_BASE/MesloLGS%20NF%20Bold.ttf" \
|
||||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Regular.ttf" \
|
&& curl -fsSL -o "MesloLGS NF Italic.ttf" "$MESLO_BASE/MesloLGS%20NF%20Italic.ttf" \
|
||||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Bold.ttf" \
|
&& curl -fsSL -o "MesloLGS NF Bold Italic.ttf" "$MESLO_BASE/MesloLGS%20NF%20Bold%20Italic.ttf" \
|
||||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Bold.ttf" \
|
&& printf '%s\n' \
|
||||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Italic.ttf" \
|
'd97946186e97f8d7c0139e8983abf40a1d2d086924f2c5dbf1c29bd8f2c6e57d MesloLGS NF Regular.ttf' \
|
||||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Italic.ttf" \
|
'b6c0199cf7c7483c8343ea020658925e6de0aeb318b89908152fcb4d19226003 MesloLGS NF Bold.ttf' \
|
||||||
&& curl -fsSL -o "/usr/share/fonts/truetype/meslo/MesloLGS NF Bold Italic.ttf" \
|
'6f357bcbe2597704e157a915625928bca38364a89c22a4ac36e7a116dcd392ef MesloLGS NF Italic.ttf' \
|
||||||
"https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Bold%20Italic.ttf" \
|
'56b4131adecec052c4b324efb818dd326d586dbc316fc68f98f1cae2eb8d1220 MesloLGS NF Bold Italic.ttf' \
|
||||||
&& fc-cache -f
|
| sha256sum -c
|
||||||
|
COPY --from=cua_driver --chmod=644 /tmp/huninn.ttf /usr/share/fonts/truetype/huninn/jf-openhuninn-2.1.ttf
|
||||||
|
RUN fc-cache -f
|
||||||
|
|
||||||
RUN git clone --depth=1 https://github.com/romkatv/powerlevel10k.git /usr/share/zsh-theme-powerlevel10k \
|
# zsh prompt + plugins from pinned release tarballs (reproducible, no build-time git).
|
||||||
&& git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions.git /usr/share/zsh/plugins/zsh-autosuggestions \
|
RUN set -e; mkdir -p /tmp/zsh /usr/share/zsh/plugins; \
|
||||||
&& git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting.git /usr/share/zsh/plugins/zsh-syntax-highlighting \
|
fetch() { \
|
||||||
&& git clone --depth=1 https://github.com/agkozak/zsh-z.git /usr/share/zsh/plugins/zsh-z \
|
curl -fsSL -o "/tmp/zsh/$1.tar.gz" "$2" \
|
||||||
&& rm -rf /usr/share/zsh-theme-powerlevel10k/.git \
|
&& echo "$3 /tmp/zsh/$1.tar.gz" | sha256sum -c \
|
||||||
/usr/share/zsh/plugins/zsh-autosuggestions/.git \
|
&& mkdir -p "$4" && tar -xzf "/tmp/zsh/$1.tar.gz" --strip-components=1 -C "$4"; \
|
||||||
/usr/share/zsh/plugins/zsh-syntax-highlighting/.git \
|
}; \
|
||||||
/usr/share/zsh/plugins/zsh-z/.git
|
fetch powerlevel10k https://codeload.github.com/romkatv/powerlevel10k/tar.gz/refs/tags/v1.20.0 \
|
||||||
|
d8187d44b697b3a37a8c4896678b4380e717cbf2850179529358348780a2d3d7 /usr/share/zsh-theme-powerlevel10k; \
|
||||||
|
fetch zsh-autosuggestions https://codeload.github.com/zsh-users/zsh-autosuggestions/tar.gz/refs/tags/v0.7.1 \
|
||||||
|
0df7affff21cd87ed298e6a3970ed08a1dd66a6efa676454ee5b091ad503badf /usr/share/zsh/plugins/zsh-autosuggestions; \
|
||||||
|
fetch zsh-syntax-highlighting https://codeload.github.com/zsh-users/zsh-syntax-highlighting/tar.gz/refs/tags/0.8.0 \
|
||||||
|
5981c19ebaab027e356fe1ee5284f7a021b89d4405cc53dc84b476c3aee9cc32 /usr/share/zsh/plugins/zsh-syntax-highlighting; \
|
||||||
|
fetch zsh-z https://codeload.github.com/agkozak/zsh-z/tar.gz/102fb78036ed76feedf623907483691777a1d510 \
|
||||||
|
90edc058f50447d27915accd18e022b4059fbaf1d1b34408034fb8f14eba5b9b /usr/share/zsh/plugins/zsh-z; \
|
||||||
|
rm -rf /tmp/zsh; \
|
||||||
|
test -f /usr/share/zsh-theme-powerlevel10k/powerlevel10k.zsh-theme; \
|
||||||
|
test -f /usr/share/zsh/plugins/zsh-z/zsh-z.plugin.zsh
|
||||||
|
|
||||||
RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
|
RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
|
||||||
&& mkdir -p /home/lazyboy /tmp/lazyboy /usr/share/lazyboy/skel /usr/share/lazyboy/xfce-skel /etc/gtk-3.0 /etc/fonts/conf.d \
|
&& mkdir -p /home/lazyboy /tmp/lazyboy /usr/share/lazyboy/skel /usr/share/lazyboy/xfce-skel /etc/gtk-3.0 /etc/fonts/conf.d \
|
||||||
|
|
@ -153,6 +188,7 @@ COPY --chmod=644 image/computer/xfce/xfce4-panel.xml /usr/share/lazyboy/xfce-ske
|
||||||
COPY --chmod=644 image/computer/xfce/xfwm4.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml
|
COPY --chmod=644 image/computer/xfce/xfwm4.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml
|
||||||
COPY --chmod=644 image/computer/xfce/xfce4-desktop.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml
|
COPY --chmod=644 image/computer/xfce/xfce4-desktop.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml
|
||||||
COPY --chmod=644 image/computer/xfce/thunar.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/thunar.xml
|
COPY --chmod=644 image/computer/xfce/thunar.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/thunar.xml
|
||||||
|
COPY --chmod=644 image/computer/xfce/wallpaper.svg /usr/share/lazyboy/wallpaper.svg
|
||||||
COPY --chmod=644 image/computer/xfce/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
|
COPY --chmod=644 image/computer/xfce/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
|
||||||
COPY --chmod=644 image/computer/xfce/browser.desktop /usr/share/applications/lazyboy-browser.desktop
|
COPY --chmod=644 image/computer/xfce/browser.desktop /usr/share/applications/lazyboy-browser.desktop
|
||||||
# Traditional Chinese catalogs were retained during package installation.
|
# Traditional Chinese catalogs were retained during package installation.
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,12 @@ ScrollingOnOutput=FALSE
|
||||||
ScrollingOnKeystroke=TRUE
|
ScrollingOnKeystroke=TRUE
|
||||||
ScrollingBar=TERMINAL_SCROLLBAR_NONE
|
ScrollingBar=TERMINAL_SCROLLBAR_NONE
|
||||||
ScrollingLines=20000
|
ScrollingLines=20000
|
||||||
ColorForeground=#e2e8f0
|
ColorForeground=#d3dae3
|
||||||
ColorBackground=#0b1220
|
ColorBackground=#2f343f
|
||||||
ColorCursor=#7dd3fc
|
ColorCursor=#5294e2
|
||||||
ColorBoldUseDefault=FALSE
|
ColorBoldUseDefault=FALSE
|
||||||
ColorBold=#f8fafc
|
ColorBold=#ffffff
|
||||||
TabActivityColor=#38bdf8
|
TabActivityColor=#5294e2
|
||||||
Encoding=UTF-8
|
Encoding=UTF-8
|
||||||
TitleMode=TERMINAL_TITLE_REPLACE
|
TitleMode=TERMINAL_TITLE_REPLACE
|
||||||
CommandLoginShell=TRUE
|
CommandLoginShell=TRUE
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Settings]
|
[Settings]
|
||||||
gtk-font-name=jf open 粉圓 2.1 12
|
gtk-font-name=jf open 粉圓 2.1 12
|
||||||
gtk-icon-theme-name=Adwaita
|
gtk-icon-theme-name=Papirus-Dark
|
||||||
gtk-theme-name=Adwaita-dark
|
gtk-theme-name=Arc-Dark
|
||||||
gtk-application-prefer-dark-theme=1
|
gtk-application-prefer-dark-theme=1
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ hydrate_xfce() {
|
||||||
if [[ -r /etc/xdg/xfce4/helpers.rc ]]; then
|
if [[ -r /etc/xdg/xfce4/helpers.rc ]]; then
|
||||||
cp -f /etc/xdg/xfce4/helpers.rc "$xfce_home/.config/xfce4/helpers.rc" || true
|
cp -f /etc/xdg/xfce4/helpers.rc "$xfce_home/.config/xfce4/helpers.rc" || true
|
||||||
fi
|
fi
|
||||||
|
# Terminals launched by this desktop (boot, panel, menu) inherit
|
||||||
|
# XDG_CONFIG_HOME=$xfce_home/.config, not $HOME/.config.
|
||||||
|
if [[ -r /usr/share/lazyboy/skel/terminalrc ]]; then
|
||||||
|
mkdir -p "$xfce_home/.config/xfce4/terminal"
|
||||||
|
cp -f /usr/share/lazyboy/skel/terminalrc "$xfce_home/.config/xfce4/terminal/terminalrc" || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_display() {
|
wait_display() {
|
||||||
|
|
@ -159,7 +165,6 @@ start_desktop() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
hydrate_xfce "$xfce_home"
|
hydrate_xfce "$xfce_home"
|
||||||
DISPLAY="$display" xsetroot -solid "#0f172a" >/dev/null 2>&1 || true
|
|
||||||
export DISPLAY="$display"
|
export DISPLAY="$display"
|
||||||
export XDG_CONFIG_HOME="$xfce_home/.config"
|
export XDG_CONFIG_HOME="$xfce_home/.config"
|
||||||
export XDG_CACHE_HOME="$xfce_home/.cache"
|
export XDG_CACHE_HOME="$xfce_home/.cache"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
WebBrowser=lazyboy-browser
|
WebBrowser=lazyboy-browser
|
||||||
MailReader=thunderbird
|
|
||||||
TerminalEmulator=lazyboy-terminal
|
TerminalEmulator=lazyboy-terminal
|
||||||
FileManager=thunar
|
FileManager=thunar
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1200" viewBox="0 0 1920 1200">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="base" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#383c4a"/>
|
||||||
|
<stop offset="0.55" stop-color="#2f343f"/>
|
||||||
|
<stop offset="1" stop-color="#1b1e24"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glowBlue" cx="0.78" cy="0.22" r="0.55">
|
||||||
|
<stop offset="0" stop-color="#5294e2" stop-opacity="0.38"/>
|
||||||
|
<stop offset="0.5" stop-color="#5294e2" stop-opacity="0.10"/>
|
||||||
|
<stop offset="1" stop-color="#5294e2" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="glowTeal" cx="0.12" cy="0.88" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#4dd0c4" stop-opacity="0.22"/>
|
||||||
|
<stop offset="0.6" stop-color="#4dd0c4" stop-opacity="0.05"/>
|
||||||
|
<stop offset="1" stop-color="#4dd0c4" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1920" height="1200" fill="url(#base)"/>
|
||||||
|
<rect width="1920" height="1200" fill="url(#glowBlue)"/>
|
||||||
|
<rect width="1920" height="1200" fill="url(#glowTeal)"/>
|
||||||
|
<g fill="none" stroke="#5294e2" stroke-opacity="0.16" stroke-width="2">
|
||||||
|
<circle cx="1500" cy="260" r="220"/>
|
||||||
|
<circle cx="1500" cy="260" r="340"/>
|
||||||
|
<circle cx="1500" cy="260" r="470"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1.5">
|
||||||
|
<path d="M0 900 C 400 780, 800 1020, 1200 880 S 1700 760, 1920 840"/>
|
||||||
|
<path d="M0 980 C 420 860, 820 1100, 1220 960 S 1720 840, 1920 920"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -18,16 +18,32 @@
|
||||||
<property name="show-icons" type="bool" value="true"/>
|
<property name="show-icons" type="bool" value="true"/>
|
||||||
<property name="show-workspace-names" type="bool" value="false"/>
|
<property name="show-workspace-names" type="bool" value="false"/>
|
||||||
</property>
|
</property>
|
||||||
|
<!-- Xvfb's RANDR output is named "screen", so xfdesktop reads monitorscreen;
|
||||||
|
monitor0 covers displays that report a numbered output. -->
|
||||||
<property name="backdrop" type="empty">
|
<property name="backdrop" type="empty">
|
||||||
<property name="screen0" type="empty">
|
<property name="screen0" type="empty">
|
||||||
|
<property name="monitorscreen" type="empty">
|
||||||
|
<property name="workspace0" type="empty">
|
||||||
|
<property name="color-style" type="int" value="0"/>
|
||||||
|
<property name="image-style" type="int" value="5"/>
|
||||||
|
<property name="last-image" type="string" value="/usr/share/lazyboy/wallpaper.svg"/>
|
||||||
|
<property name="rgba1" type="array">
|
||||||
|
<value type="double" value="0.184314"/>
|
||||||
|
<value type="double" value="0.203922"/>
|
||||||
|
<value type="double" value="0.247059"/>
|
||||||
|
<value type="double" value="1"/>
|
||||||
|
</property>
|
||||||
|
</property>
|
||||||
|
</property>
|
||||||
<property name="monitor0" type="empty">
|
<property name="monitor0" type="empty">
|
||||||
<property name="workspace0" type="empty">
|
<property name="workspace0" type="empty">
|
||||||
<property name="color-style" type="int" value="0"/>
|
<property name="color-style" type="int" value="0"/>
|
||||||
<property name="image-style" type="int" value="0"/>
|
<property name="image-style" type="int" value="5"/>
|
||||||
|
<property name="last-image" type="string" value="/usr/share/lazyboy/wallpaper.svg"/>
|
||||||
<property name="rgba1" type="array">
|
<property name="rgba1" type="array">
|
||||||
<value type="double" value="0.0588235"/>
|
<value type="double" value="0.184314"/>
|
||||||
<value type="double" value="0.0901961"/>
|
<value type="double" value="0.203922"/>
|
||||||
<value type="double" value="0.164706"/>
|
<value type="double" value="0.247059"/>
|
||||||
<value type="double" value="1"/>
|
<value type="double" value="1"/>
|
||||||
</property>
|
</property>
|
||||||
</property>
|
</property>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@
|
||||||
<value type="int" value="1"/>
|
<value type="int" value="1"/>
|
||||||
<property name="dark-mode" type="bool" value="true"/>
|
<property name="dark-mode" type="bool" value="true"/>
|
||||||
<property name="panel-1" type="empty">
|
<property name="panel-1" type="empty">
|
||||||
<property name="position" type="string" value="p=8;x=640;y=798"/>
|
<property name="position" type="string" value="p=8;x=640;y=782"/>
|
||||||
<property name="length" type="uint" value="100"/>
|
<property name="length" type="uint" value="100"/>
|
||||||
<property name="position-locked" type="bool" value="true"/>
|
<property name="position-locked" type="bool" value="true"/>
|
||||||
<property name="size" type="uint" value="40"/>
|
<property name="size" type="uint" value="36"/>
|
||||||
<property name="icon-size" type="uint" value="24"/>
|
<property name="icon-size" type="uint" value="22"/>
|
||||||
<property name="background-style" type="uint" value="0"/>
|
<property name="background-style" type="uint" value="0"/>
|
||||||
<property name="autohide-behavior" type="uint" value="0"/>
|
<property name="autohide-behavior" type="uint" value="0"/>
|
||||||
<property name="mode" type="uint" value="0"/>
|
<property name="mode" type="uint" value="0"/>
|
||||||
|
|
@ -25,13 +25,14 @@
|
||||||
<property name="plugins" type="empty">
|
<property name="plugins" type="empty">
|
||||||
<property name="plugin-1" type="string" value="applicationsmenu">
|
<property name="plugin-1" type="string" value="applicationsmenu">
|
||||||
<property name="show-generic-names" type="bool" value="false"/>
|
<property name="show-generic-names" type="bool" value="false"/>
|
||||||
|
<property name="show-button-icon" type="bool" value="true"/>
|
||||||
<property name="show-button-title" type="bool" value="true"/>
|
<property name="show-button-title" type="bool" value="true"/>
|
||||||
<property name="button-title" type="string" value="應用程式"/>
|
<property name="button-title" type="string" value="應用程式"/>
|
||||||
</property>
|
</property>
|
||||||
<property name="plugin-2" type="string" value="tasklist">
|
<property name="plugin-2" type="string" value="tasklist">
|
||||||
<property name="show-handle" type="bool" value="false"/>
|
<property name="show-handle" type="bool" value="false"/>
|
||||||
<property name="show-labels" type="bool" value="true"/>
|
<property name="show-labels" type="bool" value="true"/>
|
||||||
<property name="flat-buttons" type="bool" value="false"/>
|
<property name="flat-buttons" type="bool" value="true"/>
|
||||||
<property name="grouping" type="uint" value="0"/>
|
<property name="grouping" type="uint" value="0"/>
|
||||||
<property name="include-all-workspaces" type="bool" value="true"/>
|
<property name="include-all-workspaces" type="bool" value="true"/>
|
||||||
</property>
|
</property>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<property name="snap_to_windows" type="bool" value="true"/>
|
<property name="snap_to_windows" type="bool" value="true"/>
|
||||||
<property name="title_font" type="string" value="jf open 粉圓 2.1 11"/>
|
<property name="title_font" type="string" value="jf open 粉圓 2.1 11"/>
|
||||||
<property name="button_layout" type="string" value="O|HMC"/>
|
<property name="button_layout" type="string" value="O|HMC"/>
|
||||||
<property name="theme" type="string" value="Default"/>
|
<property name="theme" type="string" value="Arc-Dark"/>
|
||||||
<property name="double_click_action" type="string" value="maximize"/>
|
<property name="double_click_action" type="string" value="maximize"/>
|
||||||
</property>
|
</property>
|
||||||
</channel>
|
</channel>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- Group routing: an explicit host, and the audience resolved per message.
|
||||||
|
-- A room used to wake every member for every message; the audience is now
|
||||||
|
-- decided once when the message arrives, so it is stored with the message.
|
||||||
|
|
||||||
|
ALTER TABLE rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS host_bot_id TEXT REFERENCES bots (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE messages
|
||||||
|
ADD COLUMN IF NOT EXISTS reply_bot_ids TEXT[];
|
||||||
|
|
||||||
|
-- Existing rooms keep today's behaviour: the first member is the host.
|
||||||
|
UPDATE rooms
|
||||||
|
SET host_bot_id = (
|
||||||
|
SELECT m.bot_id
|
||||||
|
FROM room_members m
|
||||||
|
WHERE m.room_id = rooms.id
|
||||||
|
ORDER BY m.created_at, m.bot_id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE host_bot_id IS NULL;
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
-- Deleting a conversation that holds a remembered message used to fail.
|
||||||
|
--
|
||||||
|
-- `DELETE FROM threads` cascades in two steps that Postgres runs one after the
|
||||||
|
-- other: the thread's messages are removed first, and only then is
|
||||||
|
-- memory_items.session_id set to NULL. That second step fires this guard while
|
||||||
|
-- source_message_id still points at a message that is already gone, so the
|
||||||
|
-- guard raised "memory message is outside agent scope" and the delete rolled
|
||||||
|
-- back — the human saw a conversation that could not be removed.
|
||||||
|
--
|
||||||
|
-- The guard now checks only the references that this write actually changes.
|
||||||
|
-- A referential action that clears one column leaves the others as they were,
|
||||||
|
-- and those were validated when they were written. An INSERT, or a move to
|
||||||
|
-- another agent/space/user, still validates everything; pointing the item at a
|
||||||
|
-- different session re-checks that its run and message belong to that session.
|
||||||
|
-- The same race exists for runs (also removed with the thread), so clearing
|
||||||
|
-- session_id never re-checks the run or message.
|
||||||
|
CREATE OR REPLACE FUNCTION validate_memory_item_scope() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
moved BOOLEAN := TG_OP = 'INSERT'
|
||||||
|
OR NEW.bot_id IS DISTINCT FROM OLD.bot_id
|
||||||
|
OR NEW.space_id IS DISTINCT FROM OLD.space_id
|
||||||
|
OR NEW.user_id IS DISTINCT FROM OLD.user_id;
|
||||||
|
resessioned BOOLEAN := TG_OP = 'UPDATE'
|
||||||
|
AND NEW.session_id IS NOT NULL
|
||||||
|
AND NEW.session_id IS DISTINCT FROM OLD.session_id;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.session_id IS NOT NULL
|
||||||
|
AND (moved OR resessioned)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM threads t
|
||||||
|
WHERE t.id = NEW.session_id AND (
|
||||||
|
(t.room_id IS NULL AND t.bot_id = NEW.bot_id)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM rooms room JOIN room_members member ON member.room_id=room.id
|
||||||
|
WHERE room.id=t.room_id AND member.bot_id=NEW.bot_id
|
||||||
|
AND room.space_id=NEW.space_id AND room.user_id=NEW.user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND t.space_id = NEW.space_id AND t.user_id = NEW.user_id
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'memory session is outside agent scope';
|
||||||
|
END IF;
|
||||||
|
IF NEW.source_run_id IS NOT NULL
|
||||||
|
AND (moved OR resessioned OR NEW.source_run_id IS DISTINCT FROM OLD.source_run_id)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM runs r
|
||||||
|
WHERE r.id = NEW.source_run_id AND r.bot_id = NEW.bot_id
|
||||||
|
AND r.space_id = NEW.space_id AND r.user_id = NEW.user_id
|
||||||
|
AND (NEW.session_id IS NULL OR r.thread_id = NEW.session_id)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'memory run is outside agent scope';
|
||||||
|
END IF;
|
||||||
|
IF NEW.source_message_id IS NOT NULL
|
||||||
|
AND (moved OR resessioned OR NEW.source_message_id IS DISTINCT FROM OLD.source_message_id)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM messages m
|
||||||
|
JOIN threads t ON t.id = m.thread_id
|
||||||
|
WHERE m.id = NEW.source_message_id AND (
|
||||||
|
(t.room_id IS NULL AND t.bot_id = NEW.bot_id)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM rooms room JOIN room_members member ON member.room_id=room.id
|
||||||
|
WHERE room.id=t.room_id AND member.bot_id=NEW.bot_id
|
||||||
|
AND room.space_id=NEW.space_id AND room.user_id=NEW.user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND t.space_id = NEW.space_id AND t.user_id = NEW.user_id
|
||||||
|
AND (NEW.session_id IS NULL OR t.id = NEW.session_id)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'memory message is outside agent scope';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Capture README hero screenshots from a running LazyBoy instance.
|
||||||
|
//
|
||||||
|
// The banner must show the real app, so this drives a real browser against a
|
||||||
|
// real stack instead of painting a mock-up. No npm dependencies: it speaks
|
||||||
|
// Chrome DevTools Protocol over the browser WebSocket (Node 22+).
|
||||||
|
//
|
||||||
|
// node scripts/capture-hero.mjs --out docs/hero/agent.png
|
||||||
|
// node scripts/capture-hero.mjs --out docs/hero/room.png --pick 測試聊天
|
||||||
|
// node scripts/capture-hero.mjs --out docs/hero/desktop.png --selector .side-card
|
||||||
|
//
|
||||||
|
// `--lang` pins the UI language so each README gets a banner and a screenshot
|
||||||
|
// in the same language; the app itself defaults to zh-TW.
|
||||||
|
//
|
||||||
|
// node scripts/capture-hero.mjs --lang en --out docs/hero/room-en.png --pick 測試聊天
|
||||||
|
//
|
||||||
|
// A capture that goes into the repository must not photograph whatever the
|
||||||
|
// agent's browser happens to have open. `--hide` blanks a region (its layout
|
||||||
|
// box stays, so nothing reflows) and `--anchor` prints where that region sits
|
||||||
|
// inside the shot, which is what docs/hero.html uses to overlay a clean panel.
|
||||||
|
//
|
||||||
|
// node scripts/capture-hero.mjs --pick 測試聊天 --hide .side-card --anchor .side-card \
|
||||||
|
// --out docs/hero/room.png
|
||||||
|
// node scripts/capture-hero.mjs --pick 阿狗 --selector .side-card --out docs/hero/desktop.png
|
||||||
|
//
|
||||||
|
// Environment:
|
||||||
|
// LB_URL stack to photograph (default http://127.0.0.1:3101)
|
||||||
|
// LB_TOKEN login token (default: LAZYBOY_APP_TOKEN from .env)
|
||||||
|
// CHROME browser binary (default: discovered, see findChrome)
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
const option = (name, fallback) => {
|
||||||
|
const index = process.argv.indexOf(`--${name}`);
|
||||||
|
return index > -1 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
base: (process.env.LB_URL || 'http://127.0.0.1:3101').replace(/\/$/, ''),
|
||||||
|
out: option('out', 'docs/hero/app.png'),
|
||||||
|
pick: option('pick', ''),
|
||||||
|
selector: option('selector', '.app-shell'),
|
||||||
|
lang: option('lang', 'zh-TW'),
|
||||||
|
hide: option('hide', ''),
|
||||||
|
anchor: option('anchor', ''),
|
||||||
|
width: Number(option('width', 1480)),
|
||||||
|
// 1480x650 keeps the 1184x520 image box in docs/hero.html free of cropping.
|
||||||
|
height: Number(option('height', 650)),
|
||||||
|
scale: Number(option('scale', 2)),
|
||||||
|
settle: Number(option('settle', 6000)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!['zh-TW', 'en'].includes(config.lang)) {
|
||||||
|
throw new Error(`--lang must be zh-TW or en, got "${config.lang}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChrome() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.CHROME,
|
||||||
|
...['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'brave', 'microsoft-edge']
|
||||||
|
.map((name) => process.env.PATH.split(path.delimiter).map((dir) => path.join(dir, name)).find((bin) => fs.existsSync(bin))),
|
||||||
|
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
||||||
|
const playwright = path.join(os.homedir(), '.cache/ms-playwright');
|
||||||
|
if (fs.existsSync(playwright)) {
|
||||||
|
for (const build of fs.readdirSync(playwright).sort().reverse()) {
|
||||||
|
for (const dir of ['chrome-linux64', 'chrome-mac']) {
|
||||||
|
const bin = path.join(playwright, build, dir, 'chrome');
|
||||||
|
if (fs.existsSync(bin)) return bin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('no Chromium found; set CHROME=/path/to/chrome');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToken() {
|
||||||
|
if (process.env.LB_TOKEN) return process.env.LB_TOKEN;
|
||||||
|
const file = path.join(root, '.env');
|
||||||
|
const match = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').match(/^LAZYBOY_APP_TOKEN=(.+)$/m) : null;
|
||||||
|
if (!match) throw new Error('no login token; set LB_TOKEN or create .env with make env');
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVersion(port) {
|
||||||
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||||||
|
if (response.ok) return await response.json();
|
||||||
|
} catch {
|
||||||
|
// Chrome is still coming up.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
throw new Error('Chrome did not expose a DevTools endpoint');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal flat CDP client: one WebSocket, promise-per-id, plus event waiting.
|
||||||
|
class Connection {
|
||||||
|
constructor(socket) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.nextId = 1;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.listeners = new Map();
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.id) {
|
||||||
|
const waiter = this.pending.get(message.id);
|
||||||
|
if (!waiter) return;
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
if (message.error) waiter.reject(new Error(`${message.method}: ${message.error.message}`));
|
||||||
|
else waiter.resolve(message.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handlers = this.listeners.get(message.method) || [];
|
||||||
|
this.listeners.set(message.method, handlers.filter((handler) => !handler.once));
|
||||||
|
handlers.forEach((handler) => handler.resolve(message.params));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method, params = {}, sessionId) {
|
||||||
|
const id = this.nextId;
|
||||||
|
this.nextId += 1;
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||||
|
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor(method, sessionId) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handlers = this.listeners.get(method) || [];
|
||||||
|
handlers.push({ resolve, once: true });
|
||||||
|
this.listeners.set(method, handlers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry an evaluate call until it returns something other than null. The app
|
||||||
|
// loads its lists over the API, so a single shot races the first render.
|
||||||
|
const retry = async (attempt) => {
|
||||||
|
for (let pass = 0; pass < 40; pass += 1) {
|
||||||
|
const value = await attempt();
|
||||||
|
if (value) return value;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const launch = async () => {
|
||||||
|
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'lazyboy-hero-'));
|
||||||
|
const port = 9400 + Math.floor(Math.random() * 200);
|
||||||
|
const args = [
|
||||||
|
'--headless=new',
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
`--user-data-dir=${profile}`,
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--hide-scrollbars',
|
||||||
|
'--force-color-profile=srgb',
|
||||||
|
`--window-size=${config.width},${config.height}`,
|
||||||
|
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
||||||
|
'about:blank',
|
||||||
|
];
|
||||||
|
const chrome = spawn(findChrome(), args, { stdio: 'ignore' });
|
||||||
|
chrome.on('exit', (code) => {
|
||||||
|
if (code && code !== 0) console.error(`chrome exited early with code ${code}`);
|
||||||
|
});
|
||||||
|
const cleanup = () => {
|
||||||
|
chrome.kill('SIGKILL');
|
||||||
|
fs.rmSync(profile, { recursive: true, force: true });
|
||||||
|
};
|
||||||
|
const version = await waitForVersion(port);
|
||||||
|
return { cleanup, version };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { cleanup, version } = await launch();
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(version.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.addEventListener('open', resolve, { once: true });
|
||||||
|
socket.addEventListener('error', () => reject(new Error('DevTools WebSocket failed')), { once: true });
|
||||||
|
});
|
||||||
|
const connection = new Connection(socket);
|
||||||
|
const { targetId } = await connection.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { sessionId } = await connection.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
await connection.send('Page.enable', {}, sessionId);
|
||||||
|
await connection.send(
|
||||||
|
'Emulation.setDeviceMetricsOverride',
|
||||||
|
{ width: config.width, height: config.height, deviceScaleFactor: config.scale, mobile: false },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const goto = async (url) => {
|
||||||
|
const loaded = connection.waitFor('Page.loadEventFired', sessionId);
|
||||||
|
await connection.send('Page.navigate', { url }, sessionId);
|
||||||
|
await loaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
await goto(`${config.base}/`);
|
||||||
|
const status = await connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(readToken())}})}).then(r=>r.status)`,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
).then((result) => result.result?.value);
|
||||||
|
if (status !== 200) throw new Error(`login failed with HTTP ${status}`);
|
||||||
|
// The app reads its locale once, at module init, so pin it before the reload
|
||||||
|
// that mounts the real view.
|
||||||
|
await connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{ expression: `localStorage.setItem('lazyboy.locale', ${JSON.stringify(config.lang)})` },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
await goto(`${config.base}/`);
|
||||||
|
|
||||||
|
if (config.pick) {
|
||||||
|
// The sidebar fills in after the first API responses, so retry until the
|
||||||
|
// entry is really there before clicking.
|
||||||
|
const clicked = await retry(async () => connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `(() => {
|
||||||
|
const label = ${JSON.stringify(config.pick)};
|
||||||
|
const hits = [...document.querySelectorAll('button,[role="button"],li,div')].filter((el) => {
|
||||||
|
const text = (el.innerText || '').trim();
|
||||||
|
return text.startsWith(label) && text.length < 90 && el.offsetParent !== null
|
||||||
|
&& el.getBoundingClientRect().width < 420;
|
||||||
|
});
|
||||||
|
if (!hits.length) return null;
|
||||||
|
hits.sort((a, b) => a.innerText.length - b.innerText.length)[0].click();
|
||||||
|
return 'clicked';
|
||||||
|
})()`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
).then((result) => result.result?.value));
|
||||||
|
if (clicked !== 'clicked') throw new Error(`sidebar entry "${config.pick}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The noVNC panel is a live stream; give it time to paint a real desktop.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, config.settle));
|
||||||
|
|
||||||
|
if (config.hide) {
|
||||||
|
const hidden = await connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `document.querySelectorAll(${JSON.stringify(config.hide)}).length`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
).then((result) => result.result?.value);
|
||||||
|
// A typo in --hide would silently ship a photograph of the live desktop.
|
||||||
|
if (!hidden) throw new Error(`--hide ${config.hide} matched nothing; refusing to capture`);
|
||||||
|
// A stylesheet survives React re-renders; an inline style does not.
|
||||||
|
await connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
const rules = ${JSON.stringify(config.hide)}.split(',')
|
||||||
|
.map((s) => s.trim()).filter(Boolean)
|
||||||
|
.map((s) => s + ' { visibility: hidden !important }').join('\\n');
|
||||||
|
style.textContent = rules;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
return document.querySelectorAll(${JSON.stringify(config.hide)}).length;
|
||||||
|
})()`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = await connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `(() => {
|
||||||
|
const el = document.querySelector(${JSON.stringify(config.selector)});
|
||||||
|
if (!el) return null;
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||||
|
})()`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
).then((result) => result.result?.value);
|
||||||
|
if (!rect) throw new Error(`selector ${config.selector} not found`);
|
||||||
|
|
||||||
|
let placed = '';
|
||||||
|
if (config.anchor) {
|
||||||
|
const box = await connection.send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `(() => {
|
||||||
|
const host = document.querySelector(${JSON.stringify(config.selector)});
|
||||||
|
const el = document.querySelector(${JSON.stringify(config.anchor)});
|
||||||
|
if (!host || !el) return null;
|
||||||
|
const a = el.getBoundingClientRect();
|
||||||
|
const b = host.getBoundingClientRect();
|
||||||
|
return { x: a.x - b.x, y: a.y - b.y, width: a.width, height: a.height };
|
||||||
|
})()`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
).then((result) => result.result?.value);
|
||||||
|
if (!box) throw new Error(`anchor ${config.anchor} not found`);
|
||||||
|
placed = ` anchor ${box.x.toFixed(1)},${box.y.toFixed(1)} ${box.width.toFixed(1)}x${box.height.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shot = await connection.send(
|
||||||
|
'Page.captureScreenshot',
|
||||||
|
{ format: 'png', clip: { ...rect, scale: 1 }, captureBeyondViewport: true },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
const file = path.resolve(root, config.out);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, Buffer.from(shot.data, 'base64'));
|
||||||
|
const bytes = fs.statSync(file).size;
|
||||||
|
console.log(`wrote ${path.relative(root, file)} ${Math.round(rect.width * config.scale)}x${Math.round(rect.height * config.scale)} (${bytes} bytes)${placed}`);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,26 +1,60 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Render docs/hero.html to docs/readme-hero.png
|
# Render docs/hero.html into both README banners
|
||||||
# Same idea as Rakazo's README banner: a designed HTML frame (logo + headline
|
#
|
||||||
# + product window), then a browser screenshot. Not an image-model generation,
|
# The banner only frames a real capture (see scripts/capture-hero.mjs), so this
|
||||||
# so every character on the screen stays exact.
|
# step needs any headless Chromium: CHROME=/path/to/chrome, a browser on PATH,
|
||||||
|
# the macOS Brave/Chrome install, or a Playwright download.
|
||||||
|
#
|
||||||
|
# Each language gets its own frame copy and its own screenshot, so the English
|
||||||
|
# README never shows a Chinese screen and the Chinese README never shows English.
|
||||||
|
#
|
||||||
|
# scripts/render-readme-hero.sh
|
||||||
|
# -> docs/readme-hero.png, docs/readme-hero.zh-TW.png
|
||||||
|
# HERO_LANG=en scripts/render-readme-hero.sh # one language only
|
||||||
|
# HERO_LANG=en OUT=/tmp/preview.png scripts/render-readme-hero.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
html="$root/docs/hero.html"
|
html="$root/docs/hero.html"
|
||||||
out="$root/docs/readme-hero.png"
|
size="${SIZE:-1280,760}"
|
||||||
brave="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
# Deliberately not LANG, which is the system locale and already set.
|
||||||
if [[ ! -x "$brave" ]]; then
|
langs="${HERO_LANG:-en zh-TW}"
|
||||||
echo "Need Brave at $brave (or edit this script to your Chromium)." >&2
|
|
||||||
|
browser=""
|
||||||
|
for candidate in "${CHROME:-}" \
|
||||||
|
"$(command -v chromium 2>/dev/null || true)" \
|
||||||
|
"$(command -v chromium-browser 2>/dev/null || true)" \
|
||||||
|
"$(command -v google-chrome 2>/dev/null || true)" \
|
||||||
|
"$(command -v brave-browser 2>/dev/null || true)" \
|
||||||
|
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; do
|
||||||
|
if [ -n "$candidate" ] && [ -x "$candidate" ]; then browser="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
if [ -z "$browser" ]; then
|
||||||
|
for cached in "$HOME"/.cache/ms-playwright/chromium-*/chrome-*/chrome; do
|
||||||
|
if [ -x "$cached" ]; then browser="$cached"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$browser" ]; then
|
||||||
|
echo "No Chromium found. Set CHROME=/path/to/chrome and try again." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
"$brave" \
|
|
||||||
--headless=new \
|
for lang in $langs; do
|
||||||
--disable-gpu \
|
case "$lang" in
|
||||||
--hide-scrollbars \
|
zh-TW) name="readme-hero.zh-TW" ;;
|
||||||
--force-device-scale-factor=2 \
|
en) name="readme-hero" ;;
|
||||||
--window-size=1280,640 \
|
*) echo "unknown HERO_LANG: $lang (expected: en, zh-TW)" >&2; exit 1 ;;
|
||||||
--screenshot="$out" \
|
esac
|
||||||
"file://$html"
|
out="${OUT:-$root/docs/$name.png}"
|
||||||
python3 - "$out" <<'PY'
|
args=(--headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=2
|
||||||
|
--window-size="$size" --screenshot="$out")
|
||||||
|
# Root needs the sandbox off; nowhere else does.
|
||||||
|
[ "$(id -u)" = "0" ] && args+=(--no-sandbox)
|
||||||
|
if ! noise="$("$browser" "${args[@]}" "file://$html?lang=$lang" 2>&1)"; then
|
||||||
|
echo "$noise" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 - "$out" <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import struct, sys
|
import struct, sys
|
||||||
p = Path(sys.argv[1])
|
p = Path(sys.argv[1])
|
||||||
|
|
@ -28,3 +62,4 @@ data = p.read_bytes()
|
||||||
w, h = struct.unpack(">II", data[16:24])
|
w, h = struct.unpack(">II", data[16:24])
|
||||||
print(f"wrote {p} ({w}x{h}, {p.stat().st_size} bytes)")
|
print(f"wrote {p} ({w}x{h}, {p.stat().st_size} bytes)")
|
||||||
PY
|
PY
|
||||||
|
done
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,7 @@ test('a burst of session events settles into one refresh',()=>{
|
||||||
|
|
||||||
test('chat follows the event stream instead of a fixed two second poll',()=>{
|
test('chat follows the event stream instead of a fixed two second poll',()=>{
|
||||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||||
assert.match(app,/subscribeToSession\(activeSessionId,\(\)=>settle\.kick\(\)/);
|
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,/createCoalescer\(\(\)=>\{if\(!document\.hidden\)refresh\(\)\.catch\(\(\)=>\{\}\)\},EVENT_SETTLE_MS\)/);
|
||||||
assert.match(app,/document\.addEventListener\("visibilitychange",resume\)/);
|
assert.match(app,/document\.addEventListener\("visibilitychange",resume\)/);
|
||||||
assert.doesNotMatch(app,/const timer=setInterval\(\(\)=>\{refresh\(\)/,'the 2s transcript poll should be gone');
|
assert.doesNotMatch(app,/const timer=setInterval\(\(\)=>\{refresh\(\)/,'the 2s transcript poll should be gone');
|
||||||
|
|
@ -622,3 +622,93 @@ for(const hostUpdatesDuringReconnect of [false,true])test(`reconnect preserves s
|
||||||
assert.equal(instances.at(-1).viewOnly,false,'a handoff made mid-reconnect still owns the new session');
|
assert.equal(instances.at(-1).viewOnly,false,'a handoff made mid-reconnect still owns the new session');
|
||||||
assert.equal(instances.at(-1).focused,true,'taking over lands the first keystroke');
|
assert.equal(instances.at(-1).focused,true,'taking over lands the first keystroke');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Who speaks in a room is decided by three small pure functions, so the `@`
|
||||||
|
// list is run rather than read: which token the caret is writing, who that token
|
||||||
|
// could mean, and what the composer holds after a pick.
|
||||||
|
const mentionsJs=ts.transpileModule(fs.readFileSync('apps/web/src/mentions.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||||
|
const mentionsBox={exports:{},require:()=>{throw new Error('unexpected import')}};
|
||||||
|
vm.runInNewContext(mentionsJs,mentionsBox);
|
||||||
|
const {mentionToken,mentionChoices,acceptMention}=mentionsBox.exports;
|
||||||
|
const EVERYONE='所有人';
|
||||||
|
const roster=[{id:'b1',name:'小美'},{id:'b2',name:'Alex'},{id:'b3',name:'Alexa'},{id:'b4',name:'工程小助手'}];
|
||||||
|
// The list is built inside a vm, so its arrays are not this realm's arrays:
|
||||||
|
// compare what a person sees (names, joined) rather than the objects themselves.
|
||||||
|
const names=(token,source=roster)=>mentionChoices(token,source,EVERYONE).map(choice=>choice.name).join(',');
|
||||||
|
|
||||||
|
test('a mention list opens only on the token under the caret',()=>{
|
||||||
|
assert.equal(mentionToken('@',1),'','a bare @ is still a list, not a word');
|
||||||
|
assert.equal(mentionToken('@小美',3),'小美');
|
||||||
|
assert.equal(mentionToken('嗨 @小美 @',7),'','the last word wins, the earlier mention is spent');
|
||||||
|
assert.equal(mentionToken('@小美 幫我看',4),null,'a finished name closes the list');
|
||||||
|
assert.equal(mentionToken('幫我改一下佈景',6),null,'plain text offers nobody');
|
||||||
|
assert.equal(mentionToken('mail@example.com',16),null,'an email address is not a mention');
|
||||||
|
assert.equal(mentionToken('@@',2),null,'a doubled @ is a typo, not a name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the whole-room escape hatch is the first name you are offered',()=>{
|
||||||
|
const open=mentionChoices('',roster,EVERYONE);
|
||||||
|
assert.equal(open[0].name,EVERYONE,'@ comes with the escape hatch on top');
|
||||||
|
assert.equal(open[0].member,null,'it reaches the room, so it is nobody in particular');
|
||||||
|
assert.equal(names('').split(',').length,roster.length+1,'then the whole room, in order');
|
||||||
|
assert.equal(names('').split(',')[1],roster[0].name);
|
||||||
|
assert.equal(names('所'),EVERYONE,'a token that cannot grow into 所有人 drops it');
|
||||||
|
assert.equal(names(EVERYONE).split(',')[0],EVERYONE,'typing it out keeps it offered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mention choices match loosely and stay one glance long',()=>{
|
||||||
|
assert.equal(names('alex'),'Alex,Alexa','case does not matter, room order does');
|
||||||
|
assert.equal(names('小'),'小美,工程小助手','a name anywhere in the word hits');
|
||||||
|
assert.equal(names(' nobody '),'','a token nobody can answer to offers nobody');
|
||||||
|
const crowd=Array.from({length:9},(_,index)=>({id:`b${index}`,name:`bot${index}`}));
|
||||||
|
assert.equal(names('',crowd).split(',').length,6,'six rows is the cap');
|
||||||
|
assert.equal(names('',crowd).split(',')[0],EVERYONE,'and the escape hatch still leads');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepting a mention leaves the sentence readable and the list closed',()=>{
|
||||||
|
const fresh=acceptMention('@',1,'小美');
|
||||||
|
assert.equal(fresh.text,'@小美 ');
|
||||||
|
assert.equal(fresh.caret,fresh.text.length,'the caret sits past the space, so typing continues the sentence');
|
||||||
|
assert.equal(mentionToken(fresh.text,fresh.caret),null,'and the list does not reopen behind it');
|
||||||
|
const picked=acceptMention('幫我 @小 部署',5,'小美');
|
||||||
|
assert.ok(picked.text.startsWith('幫我 @小美 '),'only the typed token is replaced');
|
||||||
|
assert.ok(picked.text.includes('部署'),'the rest of the message survives');
|
||||||
|
assert.equal(acceptMention('@小',2,'小美').text,'@小美 ','a half-typed name is replaced whole');
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a message was said is split the way chat apps split it: the day once,
|
||||||
|
// as a divider, and the clock time on each message. The words for today and
|
||||||
|
// yesterday come from the catalog; everything else is the locale's own spelling.
|
||||||
|
const chatTimeJs=ts.transpileModule(fs.readFileSync('apps/web/src/chat-time.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||||
|
const chatTimeBox={exports:{},require:()=>{throw new Error('unexpected import')}};
|
||||||
|
vm.runInNewContext(chatTimeJs,chatTimeBox);
|
||||||
|
const {dayLabel,sameDay,clockTime}=chatTimeBox.exports;
|
||||||
|
const words={today:zhTW.today,yesterday:zhTW.yesterday};
|
||||||
|
// A Wednesday afternoon, built in local time so the calendar day is unambiguous.
|
||||||
|
const now=new Date(2026,9,7,15,30);
|
||||||
|
const daysAgo=(count,hour=9)=>new Date(2026,9,7-count,hour);
|
||||||
|
|
||||||
|
test('a day is named the way people say it',()=>{
|
||||||
|
assert.equal(dayLabel(daysAgo(0,0),now,'zh-TW',words),'今天','midnight still counts as today');
|
||||||
|
assert.equal(dayLabel(daysAgo(0,23),now,'zh-TW',words),'今天','a message later today is still today, not the future');
|
||||||
|
assert.equal(dayLabel(daysAgo(1,23),now,'zh-TW',words),'昨天','yesterday means the calendar day, not 24 hours');
|
||||||
|
assert.equal(dayLabel(daysAgo(1),now,'en',{today:en.today,yesterday:en.yesterday}),'Yesterday');
|
||||||
|
assert.equal(dayLabel(daysAgo(2),now,'zh-TW',words),'星期一','the past week goes by weekday');
|
||||||
|
assert.equal(dayLabel(daysAgo(6),now,'zh-TW',words),'星期四','six days back is the last weekday');
|
||||||
|
const week=dayLabel(daysAgo(7),now,'zh-TW',words);
|
||||||
|
assert.match(week,/9\/30|9月30日/,'a week or more ago is written as a date');
|
||||||
|
assert.match(week,/週三|星期三/,'with its weekday, so it reads like a chat and not a form');
|
||||||
|
assert.ok(!/2026/.test(week),'this year is not spelled out');
|
||||||
|
assert.match(dayLabel(new Date(2025,9,1),now,'zh-TW',words),/2025/,'another year is spelled out');
|
||||||
|
assert.match(dayLabel(new Date(2026,8,15),now,'en',{today:en.today,yesterday:en.yesterday}),/Tue, 9\/15/,'English keeps the same short numeric date');
|
||||||
|
assert.equal(dayLabel('not a date',now,'zh-TW',words),'','garbage shows nothing rather than Invalid Date');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('messages split by calendar day and show only their clock time',()=>{
|
||||||
|
assert.ok(sameDay(new Date(2026,9,7,0,1),new Date(2026,9,7,23,59)),'first and last minute share a day');
|
||||||
|
assert.ok(!sameDay(new Date(2026,9,7,23,59),new Date(2026,9,8,0,0)),'a minute past midnight is a new day');
|
||||||
|
assert.ok(!sameDay('garbage','garbage'),'two unreadable dates are not the same day');
|
||||||
|
assert.equal(clockTime(new Date(2026,9,7,9,5),'zh-TW'),'09:05','24-hour clock, zero padded');
|
||||||
|
assert.equal(clockTime(new Date(2026,9,7,15,30),'en'),'15:30');
|
||||||
|
assert.equal(clockTime('garbage','en'),'');
|
||||||
|
});
|
||||||
|
|
|
||||||