Compare commits
4 Commits
0e02c2e148
...
6f67ea79e9
| Author | SHA1 | Date |
|---|---|---|
|
|
6f67ea79e9 | |
|
|
e4ceb626ab | |
|
|
fe95b37749 | |
|
|
f8d00df017 |
13
.env.example
13
.env.example
|
|
@ -17,7 +17,20 @@ LAZYBOY_SECURE_COOKIE=false
|
|||
LAZYBOY_COMPUTER_MEMORY_MB=2048
|
||||
LAZYBOY_COMPUTER_CPUS=2
|
||||
LAZYBOY_COMPUTER_PIDS=2048
|
||||
# Only affects the Agent desktop container. Disabled by default.
|
||||
LAZYBOY_COMPUTER_SUDO=false
|
||||
# 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.
|
||||
LAZYBOY_LXCFS_ROOT=./data/lxcfs
|
||||
LAZYBOY_MEMORY_ENABLED=true
|
||||
LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed
|
||||
LAZYBOY_MEMORY_TOP_K=8
|
||||
LAZYBOY_MEMORY_BYTE_BUDGET=6000
|
||||
|
||||
# Hourly cleanup of diagnostics; conversations and current memories are retained.
|
||||
LAZYBOY_EVENT_RETENTION_DAYS=30
|
||||
LAZYBOY_CHECKPOINT_RETENTION_DAYS=7
|
||||
LAZYBOY_RUN_RETENTION_DAYS=90
|
||||
LAZYBOY_RECORDING_RETENTION_DAYS=30
|
||||
LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS=90
|
||||
LAZYBOY_DB_WARN_MB=1024
|
||||
|
|
|
|||
|
|
@ -2251,11 +2251,20 @@ dependencies = [
|
|||
name = "lazyboy-harness"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"futures-util",
|
||||
"http",
|
||||
"lazyboy-contracts",
|
||||
"reqwest 0.12.28",
|
||||
"rig-core",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4741,7 +4750,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
|||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.26.2",
|
||||
]
|
||||
|
||||
|
|
@ -4953,6 +4966,8 @@ dependencies = [
|
|||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"utf-8",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ tracing = "0.1"
|
|||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
tokio-tungstenite = { version = "0.26", features = ["connect"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
tokio-tungstenite = { version = "0.26", features = ["connect", "rustls-tls-native-roots"] }
|
||||
futures-util = "0.3"
|
||||
bollard = "0.18"
|
||||
hmac = "0.12"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ LazyBoy 是本機系統,不是雲端沙盒。機器要跑四件事:**Postgre
|
|||
| GPU | 不需要 | 模型走網路 API,畫面是 CPU 上的 Xvfb |
|
||||
| 網路 | 第一次建映像、拉套件需要 | 之後離線也能開 UI;聊天要模型金鑰能連外 |
|
||||
|
||||
每個 Agent 桌面都會套用 Docker 的 CPU、記憶體與 PID 上限,預設是 2 CPU、2 GB、2048 個 PID。可在 `.env` 調整 `LAZYBOY_COMPUTER_CPUS`、`LAZYBOY_COMPUTER_MEMORY_MB`、`LAZYBOY_COMPUTER_PIDS`。Linux 若安裝 LXCFS,將 `LAZYBOY_LXCFS_ROOT` 指到它的 `/var/lib/lxcfs`,容器內的 `htop`、`free` 等也會顯示 cgroup 配額;macOS 仍會套用配額,但 Docker Desktop 不提供這個 `/proc` 虛擬化。桌面容器不掛主機 Docker socket,也不使用 `privileged`;需要一般管理命令時才在 `.env` 設定 `LAZYBOY_COMPUTER_SUDO=true`,權限只在該 Agent 容器內生效。
|
||||
|
||||
預設一個 Team 電腦容器可同時掛最多 **8** 個螢幕(`TEAM_SCREEN_LIMIT`)。再開私人電腦就是再一個容器、再 2 GB。分頁開著時心跳會讓桌面保持熱機;關掉分頁約 10 分鐘後凍結(記憶體還在),約 6 小時後才真正停機。
|
||||
|
||||
---
|
||||
|
|
@ -111,6 +113,9 @@ Team:工作區共用一個家目錄,每個 bot 有自己的 `DISPLAY`(`:1`
|
|||
**教技能**
|
||||
你示範,容器內 CDP 錄「點了哪個控制項、填了什麼、去了哪一頁」,再抽幾個關鍵畫面。停下來後模型整理成意圖級 playbook,之後用普通工具在**當下畫面**找控制項,不是重播座標。密碼欄不錄。技能可匯出 JSON。
|
||||
|
||||
**Slash 指令與長目標**
|
||||
在 `data/skills/<name>/SKILL.md` 放工作區共用的唯讀技能,就能在輸入框打 `/name 參數` 執行;輸入 `/` 會顯示可用技能。`/goal` 是 harness 的持續執行模式,和錄製示範產生的 playbook 分開。選單支援方向鍵、Enter/Tab 選取、Esc 關閉。`/goal 目標` 會先規劃、執行並檢查結果,直到模型回報已驗證完成;只有需要登入、驗證碼、接管畫面或缺少必要資訊時才會停下來請你處理。缺少必要資訊時會記為等待輸入,補充訊息後接續原目標;停止按鈕可以取消。一般訊息仍維持 40 回合上限,教學技能 80 回合。
|
||||
|
||||
**記憶**
|
||||
`pgvector` + MiniLM(384 維)。只有你叫它記住、或它呼叫 `remember` 的內容會進長期記憶。密碼與 token 會被拒。清除對話不會清記憶。
|
||||
|
||||
|
|
@ -148,7 +153,7 @@ Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervis
|
|||
|
||||
同一 bot 已有進行中的工作時,新訊息會排隊(`queuedBehindActive`)。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。問候路徑會把工具表清空,從源頭避免「哈囉」去開電腦。
|
||||
|
||||
`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、技能 80。
|
||||
`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、教學技能 80;`/goal` 會持續到完成或明確需要人介入。目標執行期間,同一對話送進來的新訊息會作為下一輪的補充指示。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -242,6 +247,7 @@ LazyBoy/
|
|||
│ ├── computer.rs boot / 凍結 / 心跳 / 螢幕租約
|
||||
│ ├── sessions.rs 對話 CRUD、送訊息、SSE
|
||||
│ ├── skills.rs 示範錄製與蒸馏
|
||||
│ ├── file_skills.rs 讀取 data/skills 下的唯讀 SKILL.md
|
||||
│ ├── schedules.rs cron
|
||||
│ ├── vault.rs 登入保險箱
|
||||
│ ├── memory.rs pgvector 記憶
|
||||
|
|
@ -257,6 +263,7 @@ LazyBoy/
|
|||
│ └── lazyboy-screen Team 額外 DISPLAY
|
||||
├── migrations/ sqlx,檔名流水號;API 啟動時自動 migrate
|
||||
├── data/homes/ 每個電腦的家目錄(bind 進容器 /home/lazyboy)
|
||||
├── data/skills/<name>/SKILL.md 工作區共用的 slash 技能(執行時掛載)
|
||||
├── tests/ 跨語言的小測試(node:test、Python)
|
||||
├── scripts/ init-env、build-computer-image、dev
|
||||
├── docker-compose.yml 正式堆疊(Postgres + supervisor + API)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
<!doctype html><html lang="zh-Hant"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#050506"/><title>LazyBoy</title><link rel="icon" href="/favicon.svg" type="image/svg+xml"/><link rel="icon" href="/favicon.png" type="image/png" sizes="32x32"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
<!doctype html><html lang="zh-Hant"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#050506"/><title>LazyBoy</title><link rel="icon" href="/favicon.ico" sizes="any"/><link rel="icon" href="/favicon.svg" type="image/svg+xml"/><link rel="icon" href="/favicon.png" type="image/png" sizes="32x32"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
|
|
|
|||
|
|
@ -16,27 +16,60 @@ import visibility2 from "react-useanimations/lib/visibility2";
|
|||
import searchToX from "react-useanimations/lib/searchToX";
|
||||
import { api, ApiError } from "./api";
|
||||
import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar";
|
||||
import { t, type MessageKey } from "./i18n";
|
||||
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
|
||||
import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n";
|
||||
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types";
|
||||
import { ChatMarkdown, CopyMessageButton } from "./markdown";
|
||||
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, type CronPreset, type ScheduleItem } from "./schedule";
|
||||
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule";
|
||||
import { CallOverlay, PhoneIcon } from "./call";
|
||||
import { VoiceSettingsDialog } from "./voice-settings";
|
||||
|
||||
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
|
||||
const SESSION_STORE="lazyboy.sessionByBot";
|
||||
const PANE_STORE="lazyboy.rightPane";
|
||||
const WORKSPACE_STORE="lazyboy.workspace";
|
||||
type RightPart="computer"|"memory"|"settings"|"plugins"|"accounts";
|
||||
type AccountDialog="phone"|"settings"|"model"|"about"|"help"|"feedback"|null;
|
||||
type AccountDialog="phone"|"settings"|"model"|"voice"|"about"|"help"|"feedback"|null;
|
||||
function readSessionStore():Record<string,string>{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record<string,string>:{}}catch{return {}}}
|
||||
function writeSessionStore(botId:string,sessionId:string){const store=readSessionStore();store[botId]=sessionId;localStorage.setItem(SESSION_STORE,JSON.stringify(store))}
|
||||
function readPaneStore():{collapsed:boolean;part:RightPart}{try{const raw=localStorage.getItem(PANE_STORE);if(!raw)return{collapsed:false,part:"computer"};const value=JSON.parse(raw) as {collapsed?:boolean;part?:string};return{collapsed:Boolean(value.collapsed),part:value.part==="memory"||value.part==="settings"||value.part==="plugins"||value.part==="accounts"?value.part:"computer"}}catch{return{collapsed:false,part:"computer"}}}
|
||||
type WorkspacePrefs={name:string;showHidden:boolean};
|
||||
function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:name&&name!=="Local workspace"?name:t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
|
||||
function isDefaultWorkspaceName(name?:string|null){return !name||name==="Local workspace"||name==="本機工作區"}
|
||||
function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:isDefaultWorkspaceName(name)?t("localWorkspace"):name||t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
|
||||
|
||||
function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return <span className="workspace-avatar" aria-hidden="true">{initials}</span>}
|
||||
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
|
||||
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
|
||||
function isTransitionStep(step?:string|null){return step==="電腦啟動中"||step==="喚醒中"||step==="換手中"}
|
||||
function isTransitionStep(step?:string|null){return /電腦啟動中|喚醒中|換手中/.test(step||"")}
|
||||
function localizeStepDetail(detail:string){
|
||||
if(detail==="電腦啟動中"||detail==="電腦啟動中…")return t("hudBooting");
|
||||
if(detail==="喚醒中"||detail==="喚醒中…")return t("hudWaking");
|
||||
if(detail==="換手中"||detail==="換手中…")return t("hudHandoff");
|
||||
if(detail==="思考中")return t("stepThinking");
|
||||
if(detail==="看畫面")return t("stepWatchingScreen");
|
||||
if(detail==="重新確認畫面")return t("stepRecheckingScreen");
|
||||
if(detail==="填入已存帳號")return t("stepFillingLogin");
|
||||
if(detail==="列出已存帳號")return t("stepListingAccounts");
|
||||
if(detail==="列出排程")return t("stepListingSchedules");
|
||||
if(detail==="取消排程")return t("stepCancelingSchedule");
|
||||
if(detail.startsWith("讀取技能 "))return t("stepUsingSkill",{name:detail.slice("讀取技能 ".length)});
|
||||
if(detail.startsWith("排程 "))return t("stepScheduling",{name:detail.slice("排程 ".length)});
|
||||
return detail;
|
||||
}
|
||||
function localizeStep(step?:string|null){
|
||||
if(!step)return "";
|
||||
const cut=step.indexOf(": ");
|
||||
if(cut>=0){
|
||||
const head=step.slice(0,cut);
|
||||
const tail=localizeStepDetail(step.slice(cut+2));
|
||||
return `${head}: ${tail}`;
|
||||
}
|
||||
return localizeStepDetail(step);
|
||||
}
|
||||
function localizeError(message:string){
|
||||
if(message==="示範進行中:先按「完成示範」或「取消」,再送訊息。")return t("teachInProgress");
|
||||
if(message==="AI 回應逾時(120 秒)")return t("aiTimeout");
|
||||
return message;
|
||||
}
|
||||
function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean){
|
||||
const step=computer.busyStep||"";
|
||||
if(computer.state==="booting"||step==="電腦啟動中")return t("hudBooting");
|
||||
|
|
@ -45,7 +78,7 @@ function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean)
|
|||
if(connecting)return t("hudConnecting");
|
||||
return null;
|
||||
}
|
||||
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat("zh-TW",{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat("zh-TW",{weekday:"long"}).format(date);return new Intl.DateTimeFormat("zh-TW",{month:"numeric",day:"numeric"}).format(date)}
|
||||
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();const loc=dateLocale();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat(loc,{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat(loc,{weekday:"long"}).format(date);return new Intl.DateTimeFormat(loc,{month:"numeric",day:"numeric"}).format(date)}
|
||||
const ATTACH_MAX=4;
|
||||
const ATTACH_MAX_BYTES=10*1024*1024;
|
||||
const ATTACH_ACCEPT=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.md,.csv,.json,.html,.htm,.xml,.docx,.xlsx,.pptx,image/*,text/*,application/pdf";
|
||||
|
|
@ -55,8 +88,9 @@ function readAsBase64(file:File){return new Promise<string>((resolve,reject)=>{c
|
|||
function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`}
|
||||
function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()}
|
||||
function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})}
|
||||
function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as {kind:string;site?:string;why?:string;name?:string;human?:string}[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;site?:string;why?:string;name?:string;human?:string};if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun")return [value];return []})}
|
||||
function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===t("attachedFile",{name:file.name}))}
|
||||
function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as {kind:string;site?:string;why?:string;name?:string;human?:string;cron?:string}[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;site?:string;why?:string;name?:string;human?:string;cron?:string};if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun")return [value];return []})}
|
||||
function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===`Attached ${file.name}`||text===t("attachedFile",{name:file.name}))}
|
||||
function isAutoScheduleCaption(body:string,chips:{kind?:string}[]){if(!chips.some(chip=>chip.kind==="schedule"||chip.kind==="scheduleRun"))return false;const text=body.trim();return /^\[排程(試跑)?\]/.test(text)||/^\[Schedule( test)?\]/i.test(text)}
|
||||
function FileCard({file,preview,onRemove}:{file:{name:string;size?:number};preview?:string|null;onRemove?:()=>void}){const ext=fileExt(file.name);return <div className={`file-card ${onRemove?"is-pending":""}`}>{preview?<img className="file-card-thumb" src={preview} alt=""/>:<div className="file-card-badge" aria-hidden="true">{ext}</div>}<div className="file-card-meta"><strong>{file.name}</strong><small>{typeof file.size==="number"?formatBytes(file.size):ext}</small></div>{onRemove&&<button type="button" className="file-card-remove" title={t("attachRemove",{name:file.name})} onClick={onRemove}><X/></button>}</div>}
|
||||
function clientNonce(){
|
||||
const webCrypto=globalThis.crypto;
|
||||
|
|
@ -71,6 +105,7 @@ function clientNonce(){
|
|||
}
|
||||
|
||||
export function App(){
|
||||
const locale=useLocale();
|
||||
const [bots,setBots]=useState<Bot[]>([]); const [rooms,setRooms]=useState<Room[]>([]); const [activeId,setActiveId]=useState<string|null>(null); const [activeRoomId,setActiveRoomId]=useState<string|null>(null); const [busyMembers,setBusyMembers]=useState<RoomMember[]>([]);
|
||||
const [sessions,setSessions]=useState<Session[]>([]); const [activeSessionId,setActiveSessionId]=useState<string|null>(null);
|
||||
const [messages,setMessages]=useState<Message[]>([]); const [computer,setComputer]=useState<ComputerStatus>(blankComputer);
|
||||
|
|
@ -89,7 +124,8 @@ export function App(){
|
|||
const [roomContext,setRoomContext]=useState<{room:Room;x:number;y:number}|null>(null);
|
||||
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
|
||||
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
|
||||
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState<string|null>(null);
|
||||
const [voiceSettings,setVoiceSettings]=useState<VoiceSettings|null>(null); const [callOpen,setCallOpen]=useState(false);
|
||||
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [fileSkills,setFileSkills]=useState<FileSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState<string|null>(null);
|
||||
const [schedules,setSchedules]=useState<ScheduleItem[]>([]); const [scheduleDraft,setScheduleDraft]=useState<{name:string;instructions:string;enabled:boolean;preset:CronPreset;id?:string;timezone?:string;threadId?:string|null}|null>(null);
|
||||
const [scheduleError,setScheduleError]=useState<string|null>(null); const [scheduleSaving,setScheduleSaving]=useState(false); const [runningScheduleId,setRunningScheduleId]=useState<string|null>(null);
|
||||
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
|
||||
|
|
@ -124,6 +160,13 @@ export function App(){
|
|||
const savedSkills=skills.filter(skill=>skill.status==="saved");
|
||||
const skillNeedle=skillQuery.trim().toLowerCase();
|
||||
const listedSkills=skillNeedle?savedSkills.filter(skill=>skill.name.toLowerCase().includes(skillNeedle)||(skill.playbook.whenToUse||"").toLowerCase().includes(skillNeedle)||skill.goal.toLowerCase().includes(skillNeedle)):savedSkills;
|
||||
const [slashIndex,setSlashIndex]=useState(0);
|
||||
const [slashDismissed,setSlashDismissed]=useState(false);
|
||||
useEffect(()=>{setSlashIndex(0);setSlashDismissed(false)},[draft]);
|
||||
const slashToken=draft.trimStart().split(/\s/,1)[0].slice(1).toLowerCase();
|
||||
const slashSuggestions=!slashDismissed&&/^\/[^\s]*$/.test(draft.trimStart())
|
||||
? [{name:"goal",description:t("goalCommandHint"),kind:t("slashMode")},...fileSkills.map(skill=>({...skill,kind:t("slashFileSkill")}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8)
|
||||
: [];
|
||||
|
||||
const loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]);
|
||||
const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api<Bot[]>("/api/bots"),api<Room[]>("/api/rooms").catch(()=>[] as Room[])]);setBots(next);setRooms(nextRooms);setActiveRoomId(id=>id&&nextRooms.some(room=>room.id===id)?id:null);setActiveId(id=>id&&next.some(b=>b.id===id)?id:null);await loadMcp()},[loadMcp]);
|
||||
|
|
@ -143,14 +186,17 @@ export function App(){
|
|||
if(refreshSeq!==refreshSeqRef.current)return;
|
||||
setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills);setScreenUrl(status.botId===computerBot?screen.url:null)
|
||||
},[activeId,activeRoomId,activeSessionId]);
|
||||
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]);
|
||||
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(localizeError(e.message))})},[loadBots]);
|
||||
useEffect(()=>{if(!voiceSettings?.enabled)setCallOpen(false)},[voiceSettings?.enabled]);
|
||||
useEffect(()=>{api<VoiceSettings>("/api/voice/settings").then(setVoiceSettings).catch(()=>setVoiceSettings(null))},[]);
|
||||
useEffect(()=>{if(authRequired)return;api<FileSkill[]>("/api/file-skills").then(setFileSkills).catch(()=>setFileSkills([]))},[authRequired]);
|
||||
useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]);
|
||||
useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(e.message))},[loadSessions]);
|
||||
useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(localizeError(e.message)))},[loadSessions]);
|
||||
useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current="";setPendingFiles(current=>{current.forEach(file=>file.preview&&URL.revokeObjectURL(file.preview));return []})},[activeSessionId]);
|
||||
useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]);
|
||||
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]);
|
||||
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(e.message));const timer=setInterval(()=>{refresh().catch(()=>{});const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>{clearInterval(timer);refreshSeqRef.current+=1}},[activeId,activeRoomId,activeSessionId,refresh]);
|
||||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus("剪貼簿已同步")).catch(()=>setClipboardStatus("瀏覽器未允許同步,請按複製按鈕"))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
|
||||
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(localizeError(e.message)));const timer=setInterval(()=>{refresh().catch(()=>{});const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>{clearInterval(timer);refreshSeqRef.current+=1}},[activeId,activeRoomId,activeSessionId,refresh]);
|
||||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus(t("clipboardSynced"))).catch(()=>setClipboardStatus(t("clipboardSyncBlocked")))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
|
||||
useEffect(()=>{setDesktopReady(false)},[screenUrl,paneBotId]);
|
||||
useEffect(()=>{if(skipHandoffRef.current){skipHandoffRef.current=false;holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId;return}if((holderRef.current!==computer.controlHolder||paneBotRef.current!==paneBotId)&&computer.state==="running")setHandingOff(true);holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId},[computer.controlHolder,paneBotId,computer.state]);
|
||||
useEffect(()=>{if(!handingOff)return;const timer=setTimeout(()=>setHandingOff(false),1600);return()=>clearTimeout(timer)},[handingOff]);
|
||||
|
|
@ -158,6 +204,7 @@ export function App(){
|
|||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
|
||||
useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
|
||||
useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
|
||||
useEffect(()=>{setWorkspaceName(name=>isDefaultWorkspaceName(name)?t("localWorkspace"):name)},[locale]);
|
||||
useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]);
|
||||
useEffect(()=>{if(!sessionStoreKey||!activeSessionId)return;if(!sessions.some(session=>session.id===activeSessionId))return;if(!activeRoomId&&!sessions.some(session=>session.id===activeSessionId&&session.botId===activeId))return;writeSessionStore(sessionStoreKey,activeSessionId)},[sessionStoreKey,activeId,activeRoomId,activeSessionId,sessions]);
|
||||
useEffect(()=>{localStorage.setItem(PANE_STORE,JSON.stringify({collapsed:rightCollapsed,part:rightPart}))},[rightCollapsed,rightPart]);
|
||||
|
|
@ -181,7 +228,7 @@ export function App(){
|
|||
if(scheduleDraft.id)await api(`/api/schedules/${scheduleDraft.id}`,{method:"PATCH",body:JSON.stringify(body)});
|
||||
else await api(`/api/bots/${paneBot.id}/schedules`,{method:"POST",body:JSON.stringify(body)});
|
||||
setScheduleDraft(null);await reloadSchedules();
|
||||
}catch(e){setScheduleError(e instanceof Error?e.message:t("operationFailed"))}
|
||||
}catch(e){setScheduleError(e instanceof Error?localizeError(e.message):t("operationFailed"))}
|
||||
finally{setScheduleSaving(false)}
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +236,7 @@ export function App(){
|
|||
const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
|
||||
const chatName=activeRoom?.name||active?.name||"";
|
||||
|
||||
async function action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function stopChat(){if(activeSessionId)await api(`/api/sessions/${activeSessionId}/stop`,{method:"POST",body:"{}"});else if(active)await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});}
|
||||
async function startTeaching(goal:string){if(!active)return;setTeachOpen(false);await action(async()=>{await api(`/api/bots/${active.id}/skills/start`,{method:"POST",body:JSON.stringify({goal})});setComputerOpen(true)})}
|
||||
async function stopTeaching(){if(!active)return;await action(()=>api(`/api/bots/${active.id}/skills/stop`,{method:"POST",body:"{}"}));setComputerOpen(false)}
|
||||
|
|
@ -200,46 +247,46 @@ export function App(){
|
|||
async function updateSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(()=>api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook,save:true})}));setEditingSkillId(null)}
|
||||
async function deleteSkill(skill:TaughtSkill){if(!window.confirm(t("deleteSkillConfirm",{name:skill.name||skill.goal})))return;await action(()=>api(`/api/skills/${skill.id}`,{method:"DELETE"}));setEditingSkillId(null)}
|
||||
const editingSkill=editingSkillId?skills.find(skill=>skill.id===editingSkillId)||null:null;
|
||||
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}執行「${skill.name}」`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
|
||||
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}${t("runSkillNamed",{name:skill.name})}`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
|
||||
async function importSkillFile(file:File){if(!active)return;await action(async()=>{let payload:unknown;try{payload=JSON.parse(await file.text())}catch{throw new Error(t("skillImportInvalid"))}const skill=await api<TaughtSkill>(`/api/bots/${active.id}/skills/import`,{method:"POST",body:JSON.stringify(payload)});setEditingSkillId(skill.id)})}
|
||||
function addPendingFiles(list:FileList|File[]){const incoming=[...list];if(!incoming.length)return;setError(null);setPendingFiles(current=>{const next=[...current];for(const file of incoming){if(next.length>=ATTACH_MAX){setError(t("attachTooMany"));break}if(file.size>ATTACH_MAX_BYTES){setError(t("attachTooLarge"));continue}if(!attachAllowed(file)){setError(t("attachType"));continue}next.push({id:clientNonce(),file,preview:file.type.startsWith("image/")?URL.createObjectURL(file):null})}return next})}
|
||||
function removePendingFile(id:string){setPendingFiles(current=>current.filter(item=>{if(item.id===id&&item.preview)URL.revokeObjectURL(item.preview);return item.id!==id}))}
|
||||
async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();const files=pendingFiles;if((!active&&!activeRoom)||!activeSessionId||(!text&&files.length===0))return;sendingRef.current=true;setDraft("");setPendingFiles([]);historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{try{const attachments=await Promise.all(files.map(async item=>({name:item.file.name,mimeType:item.file.type,content:await readAsBase64(item.file)})));await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce(),attachments})});sentHistoryRef.current.push(text||files[0]?.file.name||"");if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();files.forEach(item=>item.preview&&URL.revokeObjectURL(item.preview));await loadSessions()}catch(error){setPendingFiles(files);throw error}})}finally{sendingRef.current=false}}
|
||||
function composerKeyDown(event:ReactKeyboardEvent<HTMLTextAreaElement>){if(event.nativeEvent.isComposing||event.key==="Process")return;const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current<history.length-1){historyIndexRef.current+=1;setDraft(history[historyIndexRef.current])}else{historyIndexRef.current=null;setDraft(historyDraftRef.current)}return}if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();if(sendingRef.current||busy)return;event.currentTarget.form?.requestSubmit()}}
|
||||
function composerKeyDown(event:ReactKeyboardEvent<HTMLTextAreaElement>){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current<history.length-1){historyIndexRef.current+=1;setDraft(history[historyIndexRef.current])}else{historyIndexRef.current=null;setDraft(historyDraftRef.current)}return}if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();if(sendingRef.current||busy)return;event.currentTarget.form?.requestSubmit()}}
|
||||
function selectSession(id:string){setActiveSessionId(id);setSessionMenuOpen(false);if(sessionStoreKey)writeSessionStore(sessionStoreKey,id)}
|
||||
async function createSession(){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{const session=await api<Session>(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function createSession(){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{const session=await api<Session>(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})}
|
||||
async function deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api<Session[]>(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?e.message:t("rememberFailed"))}}
|
||||
async function deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api<Session[]>(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?localizeError(e.message):t("rememberFailed"))}}
|
||||
function pasteText(text:string){
|
||||
const botId=paneBotId;
|
||||
if(!botId||computer.controlHolder!=="user")return;
|
||||
pasteQueueRef.current=pasteQueueRef.current.catch(()=>{}).then(async()=>{
|
||||
if(currentPaneRef.current!==botId)return;
|
||||
await api(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"clipboard",text})});
|
||||
if(currentPaneRef.current===botId)setClipboardStatus("已貼上文字");
|
||||
}).catch(()=>setClipboardStatus("貼上失敗,請確認已接管電腦後重試"));
|
||||
if(currentPaneRef.current===botId)setClipboardStatus(t("clipboardPasted"));
|
||||
}).catch(()=>setClipboardStatus(t("clipboardPasteFailed")));
|
||||
}
|
||||
async function copySelection(){
|
||||
const botId=paneBotId;if(!botId)return;
|
||||
try{const result=await api<{text:string}>(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"copy"})});
|
||||
if(currentPaneRef.current!==botId)return;
|
||||
setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus("已複製選取文字");
|
||||
}catch{setClipboardStatus("複製未同步;請按複製按鈕,或在遠端使用 Ctrl+Shift+C");}
|
||||
setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus(t("clipboardCopiedSelection"));
|
||||
}catch{setClipboardStatus(t("clipboardCopyFailed"));}
|
||||
}
|
||||
async function pasteClipboard(){try{pasteText(await navigator.clipboard.readText())}catch{setClipboardOpen(true)}}
|
||||
async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}}
|
||||
async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()}
|
||||
function openBot(bot:Bot){setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)}
|
||||
function openRoom(room:Room){setMobileNav(false);setSessionMenuOpen(false);if(rightPart==="settings")setRightPart("computer");if(room.id===activeRoomId){if(!activeSessionId){const stored=readSessionStore()[`room:${room.id}`];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveId(null);setBusyMembers([]);setActiveRoomId(room.id)}
|
||||
function openBot(bot:Bot){setCallOpen(false);setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)}
|
||||
function openRoom(room:Room){setCallOpen(false);setMobileNav(false);setSessionMenuOpen(false);if(rightPart==="settings")setRightPart("computer");if(room.id===activeRoomId){if(!activeSessionId){const stored=readSessionStore()[`room:${room.id}`];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveId(null);setBusyMembers([]);setActiveRoomId(room.id)}
|
||||
async function deleteRoom(room:Room){setRoomToDelete(null);await action(async()=>{await api(`/api/rooms/${room.id}`,{method:"DELETE"});if(activeRoomId===room.id){setActiveRoomId(null);setActiveSessionId(null);setMessages([]);setBusyMembers([])}await loadBots()})}
|
||||
function openAccount(dialog:AccountDialog){setAccountOpen(false);setAccountDialog(dialog)}
|
||||
async function logout(){setAccountOpen(false);await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setRooms([]);setMcpServers([]);setActiveId(null);setActiveRoomId(null);setAuthRequired(true)}
|
||||
async function changeComputer(operation:"boot"|"restart"){
|
||||
async function changeComputer(operation:"boot"|"restart"|"stop"){
|
||||
const botId=paneBotId;if(!botId)return;
|
||||
const previous=computer;setScreenUrl(null);setDesktopReady(false);
|
||||
setComputer(current=>({...current,state:operation==="boot"&¤t.state==="suspended"?"suspended":"booting"}));
|
||||
try{await api(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)})}
|
||||
setComputer(current=>({...current,state:operation==="stop"?current.state:operation==="boot"&¤t.state==="suspended"?"suspended":"booting"}));
|
||||
try{const status=await api<ComputerStatus>(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)});if(currentPaneRef.current===botId)setComputer(status)}
|
||||
catch(error){
|
||||
const status=await api<ComputerStatus>(`/api/computer/${botId}/status`,{signal:AbortSignal.timeout(5_000)}).catch(()=>previous);
|
||||
if(currentPaneRef.current===botId)setComputer(status);
|
||||
|
|
@ -247,6 +294,7 @@ export function App(){
|
|||
}
|
||||
}
|
||||
const startBoot=()=>changeComputer("boot");
|
||||
const stopComputer=()=>changeComputer("stop");
|
||||
const restartComputer=()=>changeComputer("restart");
|
||||
const connecting=computer.state==="running"&&Boolean(screenUrl)&&!desktopReady;
|
||||
const overlayLabel=hudLabel(computer,connecting,handingOff);
|
||||
|
|
@ -254,6 +302,7 @@ export function App(){
|
|||
const hud=paneBot&&overlayLabel?<ComputerHud bot={paneBot} label={overlayLabel}/>:null;
|
||||
const statusMembers=workingMembers;
|
||||
const topTools=<nav className="top-tools" aria-label={t("workTools")}>
|
||||
{active&&!activeRoomId?<span className="call-entry" tabIndex={!voiceSettings?.enabled?0:undefined} title={!voiceSettings?.enabled?t("voiceDisabledHint"):undefined} aria-label={!voiceSettings?.enabled?t("voiceDisabledHint"):undefined}><button type="button" className={`top-tool-button ${callOpen?"call-active":""}`} title={!voiceSettings?.enabled?t("voiceDisabledHint"):voiceSettings.ready?t("call"):t("setUpVoiceToCall")} aria-label={t("call")} disabled={!activeSessionId||!voiceSettings?.enabled} onClick={()=>{if(!voiceSettings?.enabled)return;if(!voiceSettings.ready){setAccountDialog("voice");return}setCallOpen(true)}}><PhoneIcon/></button></span>:null}
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="computer"?"active":""}`} title={t("computer")} aria-label={t("computer")} onClick={()=>openPane("computer")}><Computer/></button>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="accounts"?"active":""}`} title={t("accounts")} aria-label={t("accounts")} onClick={()=>openPane("accounts")} disabled={!paneBot}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></button>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="memory"?"active":""}`} title={t("memory")} aria-label={t("memory")} onClick={()=>openPane("memory")} disabled={!paneBot}><Brain/></button>
|
||||
|
|
@ -261,11 +310,11 @@ export function App(){
|
|||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="settings"?"active":""}`} title={active?t("botSettings"):t("settings")} aria-label={active?t("botSettings"):t("settings")} onClick={()=>active?openPane("settings"):setAccountDialog("settings")}><Settings/></button>
|
||||
</nav>;
|
||||
|
||||
if(authRequired)return <LoginScreen authenticated={async()=>{setAuthRequired(false);setError(null);try{await loadBots()}catch(e){if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e instanceof Error?e.message:t("loginFailed"))}}}/>;
|
||||
if(authRequired)return <LoginScreen authenticated={async()=>{setAuthRequired(false);setError(null);try{await loadBots()}catch(e){if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e instanceof Error?localizeError(e.message):t("loginFailed"))}}}/>;
|
||||
|
||||
return <AvatarLookProvider value={looks}><div className={`app-shell ${rightCollapsed?"right-collapsed":"right-open"}`}>
|
||||
<aside className={`sidebar ${mobileNav?"open":""}`}>
|
||||
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className="icon-button" onClick={event=>{event.stopPropagation();setCreateMenuOpen(v=>!v)}} aria-label={t("add")} aria-haspopup="menu" aria-expanded={createMenuOpen}><UseAnimations animation={plusToX} size={18} strokeColor="#dfdfe2" wrapperStyle={{pointerEvents:"none"}}/></button>{createMenuOpen&&<div className="create-menu" role="menu"><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/>{t("addBot")}</button><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/>{t("addGroup")}</button></div>}</div></div>
|
||||
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className="icon-button" onClick={event=>{event.stopPropagation();setCreateMenuOpen(v=>!v)}} aria-label={t("add")} aria-haspopup="menu" aria-expanded={createMenuOpen}><UseAnimations animation={plusToX} size={18} strokeColor="var(--ink)" wrapperStyle={{pointerEvents:"none"}}/></button>{createMenuOpen&&<div className="create-menu" role="menu"><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/>{t("addBot")}</button><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/>{t("addGroup")}</button></div>}</div></div>
|
||||
<label className="search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("search")}/></label>
|
||||
<div className="bot-list">
|
||||
{filteredRooms.length>0&&<section className="bot-group"><div className="group-label">{t("groups")}</div>{filteredRooms.map(room=><button className={`bot-row room-row ${room.id===activeRoomId?"selected":""}`} key={room.id} onClick={()=>openRoom(room)} onContextMenu={e=>{e.preventDefault();setRoomContext({room,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><AvatarStack members={room.members} online thinkingIds={room.id===activeRoomId?busyMembers.map(member=>member.id):[]}/>{room.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count:room.unreadCount})}/>}</span><span className="bot-copy"><strong>{room.name}</strong><small>{room.lastPreview||t("members",{count:room.members.length})}</small></span>{room.lastMessageAt&&<time className="row-time">{inboxTime(room.lastMessageAt)}</time>}</button>)}</section>}
|
||||
|
|
@ -282,6 +331,7 @@ export function App(){
|
|||
<button type="button" role="menuitem" onClick={()=>openAccount("phone")}><Smartphone/>{t("openOnPhone")}</button>
|
||||
<button type="button" role="menuitem" onClick={()=>openAccount("settings")}><Settings/>{t("settings")}</button>
|
||||
<button type="button" role="menuitem" onClick={()=>openAccount("model")}><BotIcon/>{t("modelSettings")}</button>
|
||||
<button type="button" role="menuitem" onClick={()=>openAccount("voice")}><PhoneIcon/>{t("voiceSettings")}</button>
|
||||
<button type="button" role="menuitem" disabled={!paneBot} onClick={()=>{setAccountOpen(false);openPane("memory")}}><Brain/>{t("memory")}</button>
|
||||
<button type="button" role="menuitem" onClick={()=>openAccount("about")}><Info/>{t("about")}</button>
|
||||
<button type="button" role="menuitem" onClick={()=>openAccount("help")}><CircleHelp/>{t("helpCenter")}</button>
|
||||
|
|
@ -297,13 +347,14 @@ export function App(){
|
|||
</aside>
|
||||
|
||||
<main className="chat-panel">
|
||||
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} size={32} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
|
||||
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>)}{!hideBody&&(message.role==="assistant"?<div className="message-stack"><div className="message-body md"><ChatMarkdown>{message.body}</ChatMarkdown></div>{message.body.trim()?<CopyMessageButton text={message.body}/>:null}</div>:<span className="message-body">{message.body}</span>)}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
|
||||
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="var(--ink)"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} size={32} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
|
||||
{callOpen&&voiceSettings?.enabled&&active&&activeSessionId&&!activeRoomId?<CallOverlay bot={active} sessionId={activeSessionId} takeover={computer.takeoverRequested||computer.controlHolder==="user"} onHangUp={()=>setCallOpen(false)} onTakeOver={()=>{setRightPart("computer");setRightCollapsed(false);if(active)void action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}}/>:null}
|
||||
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files)||isAutoScheduleCaption(message.body,chips);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{scheduleWhen(chip.cron,chip.human)}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{scheduleWhen(chip.cron,chip.human)}</small></div>)}{!hideBody&&(message.role==="assistant"?<div className="message-stack"><div className="message-body md"><ChatMarkdown>{message.body}</ChatMarkdown></div>{message.body.trim()?<CopyMessageButton text={message.body}/>:null}</div>:<span className="message-body">{message.body}</span>)}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
|
||||
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
|
||||
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
|
||||
<div className={`composer-dock ${statusMembers.length?"has-status":""}`}>
|
||||
{statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return <div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-copy"><span className="working-label">{label}</span>{!transition&&step?<span className="working-step">{step}</span>:null}</span></div>})}
|
||||
<form className={`composer ${pendingFiles.length?"has-files":""}`} onSubmit={send} onDragOver={event=>{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}><div className="plus-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className={`composer-plus ${plusOpen?"open":""}`} disabled={!activeSessionId} title={t("moreActions")} aria-label={t("moreActions")} aria-haspopup="menu" aria-expanded={plusOpen} onClick={()=>setPlusOpen(v=>!v)}><Plus/></button>{plusOpen&&<div className="plus-menu" role="menu"><button type="button" role="menuitem" disabled={!activeSessionId||Boolean(teaching)} title={t("attachFileHint")} onClick={()=>{setPlusOpen(false);attachRef.current?.click()}}><Paperclip/>{t("attachFile")}</button><button type="button" role="menuitem" disabled={!active||Boolean(teaching)||Boolean(drafting)} title={active?t("teachTaskHint"):t("teachNeedsBot")} onClick={()=>{setPlusOpen(false);setTeachOpen(true)}}><i className="record-dot"/>{t("teachTask")}</button><button type="button" role="menuitem" disabled={!active} title={t("importSkillHint")} onClick={()=>{setPlusOpen(false);importRef.current?.click()}}><Upload/>{t("importSkill")}</button>{savedSkills.length>0&&<><hr/><div className="plus-menu-skills"><small className="plus-menu-label">{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}</small>{savedSkills.length>=6&&<input className="plus-menu-search" value={skillQuery} onChange={e=>setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}<div className="plus-menu-skill-list">{listedSkills.map(skill=><div className="plus-menu-skill" key={skill.id}><button type="button" role="menuitem" title={t("runSkillNamed",{name:skill.name})+(skill.playbook.whenToUse?`\n${skill.playbook.whenToUse}`:"")} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button><button type="button" className="skill-edit" title={t("exportSkillHint")} aria-label={t("exportSkill")} onClick={()=>downloadSkill(skill.name,skill.goal,skill.playbook)}><Download/></button><button type="button" className="skill-edit" title={t("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}{listedSkills.length===0&&<small className="plus-menu-empty">{t("noMatchingSkills")}</small>}</div></div></>}</div>}</div><input ref={importRef} className="skill-import-input" type="file" accept="application/json,.json" tabIndex={-1} aria-hidden="true" onChange={event=>{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/><input ref={attachRef} className="skill-import-input attach-input" type="file" multiple accept={ATTACH_ACCEPT} tabIndex={-1} aria-hidden="true" onChange={event=>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&<div className="composer-files">{pendingFiles.map(item=><FileCard key={item.id} file={{name:item.file.name,size:item.file.size}} preview={item.preview} onRemove={()=>removePendingFile(item.id)}/>)}</div>}<textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} onPaste={event=>{const files=event.clipboardData?.files;if(files&&files.length){event.preventDefault();addPendingFiles(files)}}} placeholder={teaching?t("teachingComposerHint"):activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId||Boolean(teaching)}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||(!draft.trim()&&pendingFiles.length===0)||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
|
||||
{statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return <div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-copy"><span className="working-label">{label}</span>{!transition&&step?<span className="working-step">{localizeStep(step)}</span>:null}</span></div>})}
|
||||
<form className={`composer ${pendingFiles.length?"has-files":""}`} onSubmit={send} onDragOver={event=>{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}><div className="plus-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className={`composer-plus ${plusOpen?"open":""}`} disabled={!activeSessionId} title={t("moreActions")} aria-label={t("moreActions")} aria-haspopup="menu" aria-expanded={plusOpen} onClick={()=>setPlusOpen(v=>!v)}><Plus/></button>{plusOpen&&<div className="plus-menu" role="menu"><button type="button" role="menuitem" disabled={!activeSessionId||Boolean(teaching)} title={t("attachFileHint")} onClick={()=>{setPlusOpen(false);attachRef.current?.click()}}><Paperclip/>{t("attachFile")}</button><button type="button" role="menuitem" disabled={!active||Boolean(teaching)||Boolean(drafting)} title={active?t("teachTaskHint"):t("teachNeedsBot")} onClick={()=>{setPlusOpen(false);setTeachOpen(true)}}><i className="record-dot"/>{t("teachTask")}</button><button type="button" role="menuitem" disabled={!active} title={t("importSkillHint")} onClick={()=>{setPlusOpen(false);importRef.current?.click()}}><Upload/>{t("importSkill")}</button>{savedSkills.length>0&&<><hr/><div className="plus-menu-skills"><small className="plus-menu-label">{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}</small>{savedSkills.length>=6&&<input className="plus-menu-search" value={skillQuery} onChange={e=>setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}<div className="plus-menu-skill-list">{listedSkills.map(skill=><div className="plus-menu-skill" key={skill.id}><button type="button" role="menuitem" title={t("runSkillNamed",{name:skill.name})+(skill.playbook.whenToUse?`\n${skill.playbook.whenToUse}`:"")} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button><button type="button" className="skill-edit" title={t("exportSkillHint")} aria-label={t("exportSkill")} onClick={()=>downloadSkill(skill.name,skill.goal,skill.playbook)}><Download/></button><button type="button" className="skill-edit" title={t("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}{listedSkills.length===0&&<small className="plus-menu-empty">{t("noMatchingSkills")}</small>}</div></div></>}</div>}</div><input ref={importRef} className="skill-import-input" type="file" accept="application/json,.json" tabIndex={-1} aria-hidden="true" onChange={event=>{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/><input ref={attachRef} className="skill-import-input attach-input" type="file" multiple accept={ATTACH_ACCEPT} tabIndex={-1} aria-hidden="true" onChange={event=>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&<div className="composer-files">{pendingFiles.map(item=><FileCard key={item.id} file={{name:item.file.name,size:item.file.size}} preview={item.preview} onRemove={()=>removePendingFile(item.id)}/>)}</div>}{slashSuggestions.length>0&&<div className="slash-suggestions" role="listbox" aria-label={t("slashList")}>{slashSuggestions.map((skill,index)=><button type="button" role="option" aria-selected={index===slashIndex} key={skill.name} onClick={()=>{setDraft(`/${skill.name} `);requestAnimationFrame(()=>document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus())}}><strong>/{skill.name}</strong><small>{skill.kind}</small><span>{skill.description}</span></button>)}</div>}<textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} onPaste={event=>{const files=event.clipboardData?.files;if(files&&files.length){event.preventDefault();addPendingFiles(files)}}} placeholder={teaching?t("teachingComposerHint"):activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId||Boolean(teaching)}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||(!draft.trim()&&pendingFiles.length===0)||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)"/></button>}</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -318,7 +369,7 @@ export function App(){
|
|||
<div className={`side-part computer-part ${rightPart==="computer"?"":"hidden-part"}`}>
|
||||
<div className="computer-status-row">{paneBot?<span>{t("botComputer",{name:paneBot.name})}</span>:<span>{t("computer")}</span>}{computer.state==="booting"?<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/>:<i className={`state-dot ${computer.state}`}/>}<small>{stateLabel(computer.state)}</small></div>
|
||||
<div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}{!computerOpen&&hud}</div>
|
||||
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<p className="computer-login-hint">{t("computerLoginHint")}</p><p className="clipboard-status" role="status">{clipboardStatus}</p>{scheduleDraft?<ScheduleEditor draft={scheduleDraft} timezone={scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei"} saving={scheduleSaving} error={scheduleError} onChange={next=>setScheduleDraft(current=>current?{...current,...next}:next)} onBack={()=>setScheduleDraft(null)} onSave={()=>void saveScheduleDraft()} onDelete={scheduleDraft.id?()=>void action(async()=>{await api(`/api/schedules/${scheduleDraft.id}`,{method:"DELETE"});setScheduleDraft(null);await reloadSchedules()}):undefined}/>:<ScheduleList items={schedules} runningId={runningScheduleId} onCreate={()=>setScheduleDraft({name:"",instructions:"",enabled:true,preset:defaultCronPreset()})} onOpen={item=>setScheduleDraft({id:item.id,timezone:item.timezone,threadId:item.threadId,name:item.name,instructions:item.instructions,enabled:item.enabled,preset:presetFromCron(item.cron)})} onRun={item=>void action(async()=>{setRunningScheduleId(item.id);try{await api(`/api/schedules/${item.id}/run`,{method:"POST",body:"{}"});await loadSessions()}finally{setRunningScheduleId(null)}})}/>}</>}
|
||||
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer} onStop={stopComputer}/>}<p className="computer-login-hint">{t("computerLoginHint")}</p><p className="clipboard-status" role="status">{clipboardStatus}</p>{scheduleDraft?<ScheduleEditor draft={scheduleDraft} timezone={scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei"} saving={scheduleSaving} error={scheduleError} onChange={next=>setScheduleDraft(current=>current?{...current,...next}:next)} onBack={()=>setScheduleDraft(null)} onSave={()=>void saveScheduleDraft()} onDelete={scheduleDraft.id?()=>void action(async()=>{await api(`/api/schedules/${scheduleDraft.id}`,{method:"DELETE"});setScheduleDraft(null);await reloadSchedules()}):undefined}/>:<ScheduleList items={schedules} runningId={runningScheduleId} onCreate={()=>setScheduleDraft({name:"",instructions:"",enabled:true,preset:defaultCronPreset()})} onOpen={item=>setScheduleDraft({id:item.id,timezone:item.timezone,threadId:item.threadId,name:item.name,instructions:item.instructions,enabled:item.enabled,preset:presetFromCron(item.cron)})} onRun={item=>void action(async()=>{setRunningScheduleId(item.id);try{await api(`/api/schedules/${item.id}/run`,{method:"POST",body:"{}"});await loadSessions()}finally{setRunningScheduleId(null)}})}/>}</>}
|
||||
</div>
|
||||
{rightPart==="accounts"&&paneBot&&<VaultPane bot={paneBot}/>}
|
||||
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
|
||||
|
|
@ -328,7 +379,7 @@ export function App(){
|
|||
</>
|
||||
</aside>}
|
||||
|
||||
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.usingComputer?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><Ellipsis/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen"><div className="overlay-desktop">{frame}{hud}</div></div>{error&&<div className="overlay-error">{error}</div>}</div>}
|
||||
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.usingComputer?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer} onStop={stopComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="var(--ink)"/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen"><div className="overlay-desktop">{frame}{hud}</div></div>{error&&<div className="overlay-error">{error}</div>}</div>}
|
||||
{teachOpen&&active&&<TeachDialog bot={active} busy={busy} close={()=>setTeachOpen(false)} start={goal=>void startTeaching(goal)}/>}
|
||||
{editingSkill&&<SkillEditDialog key={editingSkill.id} skill={editingSkill} busy={busy} close={()=>setEditingSkillId(null)} save={(name,playbook)=>void updateSkill(editingSkill,name,playbook)} test={(name,playbook)=>void testSkill(editingSkill,name,playbook)} remove={()=>void deleteSkill(editingSkill)} exportFile={(name,playbook)=>downloadSkill(name,editingSkill.goal,playbook)}/>}
|
||||
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{pasteText(text);setClipboardOpen(false)}}/>}
|
||||
|
|
@ -344,13 +395,14 @@ export function App(){
|
|||
{accountDialog==="phone"&&<PhoneAccessDialog close={()=>setAccountDialog(null)}/>}
|
||||
{accountDialog==="settings"&&<WorkspaceSettingsDialog name={workspaceName} setName={setWorkspaceName} showHidden={showHidden} setShowHidden={setShowHidden} rightCollapsed={rightCollapsed} setRightCollapsed={setRightCollapsed} close={()=>setAccountDialog(null)}/>}
|
||||
{accountDialog==="model"&&<ModelSettingsDialog close={()=>setAccountDialog(null)}/>}
|
||||
{accountDialog==="voice"&&<VoiceSettingsDialog close={()=>{setAccountDialog(null);api<VoiceSettings>("/api/voice/settings").then(setVoiceSettings).catch(()=>setVoiceSettings(null))}}/>}
|
||||
{accountDialog==="about"&&<AboutDialog close={()=>setAccountDialog(null)}/>}
|
||||
{accountDialog==="help"&&<HelpDialog close={()=>setAccountDialog(null)}/>}
|
||||
{accountDialog==="feedback"&&<FeedbackDialog close={()=>setAccountDialog(null)}/>}
|
||||
</div></AvatarLookProvider>
|
||||
}
|
||||
|
||||
function BotContextMenu({context,close,run}:{context:{bot:Bot;x:number;y:number};close:()=>void;run:(action:string,group?:string|null)=>void}){const bot=context.bot;return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-280)}} onClick={e=>e.stopPropagation()}><button onClick={()=>run(bot.pinned?"unpin":"pin")}><Pin/>{bot.pinned?t("unpin"):t("pin")}</button><button onClick={()=>run("unread")}><UseAnimations animation={mail} size={15} strokeColor="#dfdfe2"/>{t("markUnread")}</button><button onClick={()=>{const name=window.prompt(t("enterGroupName"),bot.groupName||"");if(name!==null)run("group",name)}}><UseAnimations animation={folder} size={15} strokeColor="#dfdfe2"/>{bot.groupName?t("changeGroup"):t("createOrMoveGroup")}</button>{bot.groupName&&<button onClick={()=>run("group",null)}><X/>{t("removeFromGroup")}</button>}<button onClick={()=>run(bot.hidden?"show":"hide")}>{bot.hidden?<UseAnimations animation={visibility} size={15} strokeColor="#dfdfe2"/>:<UseAnimations animation={visibility2} size={15} strokeColor="#dfdfe2"/>}{bot.hidden?t("unhide"):t("hide")}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><UseAnimations animation={trash2} size={15} strokeColor="#ff7777"/>{t("delete")}</button><button className="context-close" onClick={close}><X/></button></div>}
|
||||
function BotContextMenu({context,close,run}:{context:{bot:Bot;x:number;y:number};close:()=>void;run:(action:string,group?:string|null)=>void}){const bot=context.bot;return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-280)}} onClick={e=>e.stopPropagation()}><button onClick={()=>run(bot.pinned?"unpin":"pin")}><Pin/>{bot.pinned?t("unpin"):t("pin")}</button><button onClick={()=>run("unread")}><UseAnimations animation={mail} size={15} strokeColor="var(--ink)"/>{t("markUnread")}</button><button onClick={()=>{const name=window.prompt(t("enterGroupName"),bot.groupName||"");if(name!==null)run("group",name)}}><UseAnimations animation={folder} size={15} strokeColor="var(--ink)"/>{bot.groupName?t("changeGroup"):t("createOrMoveGroup")}</button>{bot.groupName&&<button onClick={()=>run("group",null)}><X/>{t("removeFromGroup")}</button>}<button onClick={()=>run(bot.hidden?"show":"hide")}>{bot.hidden?<UseAnimations animation={visibility} size={15} strokeColor="var(--ink)"/>:<UseAnimations animation={visibility2} size={15} strokeColor="var(--ink)"/>}{bot.hidden?t("unhide"):t("hide")}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><UseAnimations animation={trash2} size={15} strokeColor="var(--danger-soft)"/>{t("delete")}</button><button className="context-close" onClick={close}><X/></button></div>}
|
||||
|
||||
function SessionMenu({sessions,activeSessionId,open,setOpen,busy,onSelect,onCreate,onDelete,onClear}:{sessions:Session[];activeSessionId:string|null;open:boolean;setOpen:(fn:(value:boolean)=>boolean)=>void;busy:boolean;onSelect:(id:string)=>void;onCreate:()=>void;onDelete:(id:string)=>void;onClear:()=>void}){
|
||||
const active=sessions.find(session=>session.id===activeSessionId);
|
||||
|
|
@ -367,7 +419,7 @@ function SessionMenu({sessions,activeSessionId,open,setOpen,busy,onSelect,onCrea
|
|||
<strong>{session.title}</strong>
|
||||
<time>{inboxTime(session.updatedAt)}</time>
|
||||
</button>
|
||||
<button type="button" className="icon-button" title={t("deleteConversation")} disabled={busy} onClick={()=>onDelete(session.id)}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button>
|
||||
<button type="button" className="icon-button" title={t("deleteConversation")} disabled={busy} onClick={()=>onDelete(session.id)}><UseAnimations animation={trash2} size={18} strokeColor="var(--ink)"/></button>
|
||||
</div>)}</div>
|
||||
<hr/>
|
||||
<button type="button" className="danger-item" disabled={busy||!activeSessionId} onClick={onClear}>{t("clearConversation")}</button>
|
||||
|
|
@ -387,8 +439,8 @@ const EXPR_I18N = {idle:"exprIdle",happy:"exprHappy",sad:"exprSad",mad:"exprMad"
|
|||
const BG_I18N = {none:"bgNone",circle:"bgCircle",squircle:"bgSquircle",square:"bgSquare"} as const satisfies Record<AvatarBackground, MessageKey>;
|
||||
|
||||
function BotSettingsPane({bot,look,onLook,saved,onDelete}:{bot:Bot;look:AvatarLook;onLook:(look:AvatarLook)=>void;saved:()=>Promise<void>|void;onDelete:()=>void}){
|
||||
const[name,setName]=useState(bot.name);const[title,setTitle]=useState(bot.title);const[description,setDescription]=useState(bot.description);const[color,setColor]=useState(bot.avatarColor||"#8B5CF6");const[shape,setShape]=useState<AvatarShape>(resolveBlobatarShape(bot.avatarShape));const[expression,setExpression]=useState<AvatarExpression>(look.expression);const[background,setBackground]=useState<AvatarBackground>(look.background);const[tagText,setTagText]=useState((bot.tags||[]).join("、"));const[busy,setBusy]=useState(false);
|
||||
useEffect(()=>{setName(bot.name);setTitle(bot.title);setDescription(bot.description);setColor(bot.avatarColor||"#8B5CF6");setShape(resolveBlobatarShape(bot.avatarShape));setExpression(look.expression);setBackground(look.background);setTagText((bot.tags||[]).join("、"))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags,look.expression,look.background]);
|
||||
const[name,setName]=useState(bot.name);const[title,setTitle]=useState(bot.title);const[description,setDescription]=useState(bot.description);const[color,setColor]=useState(bot.avatarColor||"#8B5CF6");const[shape,setShape]=useState<AvatarShape>(resolveBlobatarShape(bot.avatarShape));const[expression,setExpression]=useState<AvatarExpression>(look.expression);const[background,setBackground]=useState<AvatarBackground>(look.background);const[tagText,setTagText]=useState(listJoin(bot.tags||[]));const[busy,setBusy]=useState(false);
|
||||
useEffect(()=>{setName(bot.name);setTitle(bot.title);setDescription(bot.description);setColor(bot.avatarColor||"#8B5CF6");setShape(resolveBlobatarShape(bot.avatarShape));setExpression(look.expression);setBackground(look.background);setTagText(listJoin(bot.tags||[]))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags,look.expression,look.background]);
|
||||
const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];
|
||||
const face={name:name||bot.name,color,shape,expression,background};
|
||||
return <form className="pane-form settings-pane" onSubmit={async e=>{e.preventDefault();if(!name.trim())return;setBusy(true);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:name.trim(),title,description,avatarColor:color,avatarShape:persistBlobatarShape(shape),tags:tagText.split(/[、,,]/).map(v=>v.trim()).filter(Boolean)})});onLook({expression,background});await saved()}finally{setBusy(false)}}}>
|
||||
|
|
@ -410,8 +462,8 @@ function VaultPane({bot}:{bot:Bot}){
|
|||
const[items,setItems]=useState<Account[]>([]);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
|
||||
const[form,setForm]=useState({site:"",host:"",username:"",password:"",notes:""});
|
||||
const load=useCallback(()=>api<Account[]>(`/api/bots/${bot.id}/accounts`).then(setItems),[bot.id]);
|
||||
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
useEffect(()=>{load().catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[load]);
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
|
||||
function setField<K extends keyof typeof form>(key:K,value:string){setForm(current=>({...current,[key]:value}))}
|
||||
return <div className="vault-pane">
|
||||
<p className="memory-help">{t("accountsHelp")}</p>
|
||||
|
|
@ -425,7 +477,7 @@ function VaultPane({bot}:{bot:Bot}){
|
|||
<label>{t("accountNotes")}<input value={form.notes} onChange={e=>setField("notes",e.target.value)} placeholder={t("accountNotesPlaceholder")} autoComplete="off"/></label>
|
||||
<button className="primary" disabled={busy||!form.site.trim()||!form.username.trim()||!form.password}>{t("addAccount")}</button>
|
||||
</form>
|
||||
<div className="account-list">{items.length===0?<p className="vault-empty">{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><div className="account-row-main"><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small>{item.notes?<small className="account-notes">{item.notes}</small>:null}</div><button type="button" className="icon-button" title={t("delete")} aria-label={t("delete")} disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button></div>)}</div>
|
||||
<div className="account-list">{items.length===0?<p className="vault-empty">{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><div className="account-row-main"><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small>{item.notes?<small className="account-notes">{item.notes}</small>:null}</div><button type="button" className="icon-button" title={t("delete")} aria-label={t("delete")} disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}><UseAnimations animation={trash2} size={18} strokeColor="var(--ink)"/></button></div>)}</div>
|
||||
{error&&<div className="pane-error">{error}</div>}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -434,11 +486,11 @@ function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
|
|||
const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
|
||||
const[editingId,setEditingId]=useState<string|null>(null);const[editingText,setEditingText]=useState("");const[confirmClear,setConfirmClear]=useState(false);
|
||||
const load=useCallback(()=>api<MemoryItem[]>(`/api/bots/${bot.id}/memories`).then(setItems),[bot.id]);
|
||||
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
|
||||
useEffect(()=>{load().catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[load]);
|
||||
useEffect(()=>setEnabled(bot.memoryEnabled),[bot.id,bot.memoryEnabled]);
|
||||
const visible=items.filter(item=>!query.trim()||item.content.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function toggle(value:boolean){setEnabled(value);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:bot.name,title:bot.title,description:bot.description,avatarColor:bot.avatarColor,avatarShape:bot.avatarShape,tags:bot.tags,memoryEnabled:value})});await changed()}catch(e){setEnabled(!value);setError(e instanceof Error?e.message:t("settingsFailed"))}}
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function toggle(value:boolean){setEnabled(value);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:bot.name,title:bot.title,description:bot.description,avatarColor:bot.avatarColor,avatarShape:bot.avatarShape,tags:bot.tags,memoryEnabled:value})});await changed()}catch(e){setEnabled(!value);setError(e instanceof Error?localizeError(e.message):t("settingsFailed"))}}
|
||||
return <div className="memory-pane">
|
||||
<p className="memory-help">{t("memoryHelp")}</p>
|
||||
<label className="memory-toggle"><input type="checkbox" checked={enabled} onChange={e=>void toggle(e.target.checked)}/> {t("enableLongTermMemory")}</label>
|
||||
|
|
@ -468,7 +520,7 @@ function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
|
|||
function parsePairs(text:string){const out:Record<string,string>={};for(const line of text.split(/\n+/)){const trimmed=line.trim();if(!trimmed)continue;const cut=trimmed.indexOf("=");if(cut<=0)continue;out[trimmed.slice(0,cut).trim()]=trimmed.slice(cut+1)}return out}
|
||||
function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}){
|
||||
const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null);
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
|
||||
return <div className="mcp-pane">
|
||||
<p className="memory-help">{t("mcpHelp")}</p>
|
||||
<button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button>
|
||||
|
|
@ -499,7 +551,7 @@ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;
|
|||
const[secrets,setSecrets]=useState<Record<string,string>>({});
|
||||
const[busy,setBusy]=useState(false);const[error,setError]=useState("");
|
||||
const addedSet=useMemo(()=>new Set(added.map(name=>name.toLowerCase())),[added]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>{setLoading(true);api<{servers:McpCatalogEntry[]}>(`/api/mcp-catalog?q=${encodeURIComponent(query.trim())}`).then(result=>setItems(result.servers)).catch(e=>setError(e instanceof Error?e.message:t("loadFailed"))).finally(()=>setLoading(false))},query?280:0);return()=>clearTimeout(timer)},[query]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>{setLoading(true);api<{servers:McpCatalogEntry[]}>(`/api/mcp-catalog?q=${encodeURIComponent(query.trim())}`).then(result=>setItems(result.servers)).catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed"))).finally(()=>setLoading(false))},query?280:0);return()=>clearTimeout(timer)},[query]);
|
||||
function needsSecrets(entry:McpCatalogEntry){return entry.envKeys.some(field=>field.required)||entry.headerKeys.some(field=>field.required)}
|
||||
async function connect(entry:McpCatalogEntry,values:Record<string,string>){
|
||||
const env:Record<string,string>={};const headers:Record<string,string>={};
|
||||
|
|
@ -510,7 +562,7 @@ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;
|
|||
const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify({name:entry.title.slice(0,80),transport:entry.transport,command:entry.command,args:entry.args,url:entry.url,env,headers,enabled:true})});
|
||||
if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));return}
|
||||
await connected();
|
||||
}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}
|
||||
}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}
|
||||
finally{setBusy(false)}
|
||||
}
|
||||
function choose(entry:McpCatalogEntry){
|
||||
|
|
@ -527,7 +579,7 @@ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;
|
|||
{[...picked.envKeys,...picked.headerKeys].map(field=><label key={field.name}>{field.name}{field.required?" *":""}<input type={field.secret?"password":"text"} autoComplete="off" value={secrets[field.name]||""} onChange={e=>setSecrets(current=>({...current,[field.name]:e.target.value}))} placeholder={field.hint||field.name}/></label>)}
|
||||
{error&&<div className="pane-error">{error}</div>}
|
||||
<div className="dialog-actions"><button type="button" className="outline" onClick={()=>setPicked(null)}>{t("cancel")}</button><button type="button" className="primary" disabled={busy} onClick={()=>void connect(picked,secrets)}>{busy?t("connecting"):t("connectMcp")}</button></div>
|
||||
</div>:custom?<McpCustomForm busy={busy} error={error} onBack={()=>setCustom(false)} onSubmit={async payload=>{setBusy(true);setError("");try{const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify(payload)});if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));setBusy(false);return}await connected()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"));setBusy(false)}}}/>:<>
|
||||
</div>:custom?<McpCustomForm busy={busy} error={error} onBack={()=>setCustom(false)} onSubmit={async payload=>{setBusy(true);setError("");try{const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify(payload)});if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));setBusy(false);return}await connected()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"));setBusy(false)}}}/>:<>
|
||||
<p className="dialog-lead">{t("mcpPickerLead")}</p>
|
||||
<label className="mcp-picker-search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input autoFocus value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("searchMcp")}/></label>
|
||||
<div className="mcp-picker-grid">{loading&&items.length===0?<div className="mcp-picker-empty">{t("connecting")}</div>:items.length===0?<div className="mcp-picker-empty">{t("mcpNoResults")}</div>:items.map(entry=>{const already=addedSet.has(entry.title.toLowerCase())||addedSet.has(entry.id.toLowerCase());return <button type="button" className={`mcp-pick-card ${already?"added":""}`} key={entry.id} disabled={already||busy} onClick={()=>choose(entry)}>
|
||||
|
|
@ -564,11 +616,41 @@ function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;o
|
|||
}
|
||||
|
||||
function ComputerHud({bot,label}:{bot:{id:string;name:string;avatarColor?:string;avatarShape?:AvatarShape};label:string}){
|
||||
return <div className="computer-hud" role="status" aria-label={`${bot.name}:${label}`}><span className="computer-signal" aria-hidden="true"><span className="computer-signal-face"><i/><i/></span></span><span className="computer-hud-label">{label}</span></div>
|
||||
const sep=getLocale()==="en"?": ":":";
|
||||
return <div className="computer-hud" role="status" aria-label={`${bot.name}${sep}${label}`}><span className="computer-signal" aria-hidden="true"><span className="computer-signal-face"><i/><i/></span></span><span className="computer-hud-label">{label}</span></div>
|
||||
}
|
||||
function EmptyComputer({state}:{state:ComputerStatus["state"]}){if(state==="booting"||state==="suspended")return <div className="empty-computer is-waiting" aria-hidden="true"/>;return <div className="empty-computer"><Computer/><strong>{stateLabel(state)}</strong><span>{t("computerPreviewHint")}</span></div>}
|
||||
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const working=Boolean(computer.usingComputer);const restart=<button className="outline restart-computer" disabled={busy} title={t("restartDocker")} onClick={()=>void action(onRestart||(()=>api(`/api/computer/${active.id}/restart`,{method:"POST",body:"{}"})))}><RefreshCw/> {t("restartDocker")}</button>;if(computer.state!=="running")return <div className="computer-actions"><button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>void action(onBoot||(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"})))}>{(busy||computer.state==="booting")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>{(computer.state==="booting"||computer.state==="error")&&restart}</div>;if(working)return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button><button className="outline" disabled={busy} onClick={()=>action(async()=>{if(sessionId)await api(`/api/sessions/${sessionId}/stop`,{method:"POST",body:"{}"});else await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});})}><Square/>{t("stopTask")}</button>{restart}</div>;if(computer.controlHolder==="user")return <div className="computer-actions"><button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>{restart}</div>;return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>{restart}</div>}
|
||||
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const interactive=props.computer.controlHolder==="user";return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" disabled={!interactive} onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" disabled={!interactive} onClick={props.copy}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button></div>}
|
||||
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart,onStop}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>;onStop?:()=>Promise<void>}){
|
||||
const [menuOpen,setMenuOpen]=useState(false);
|
||||
const menuRef=useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{
|
||||
if(!menuOpen)return;
|
||||
const outside=(event:PointerEvent)=>{if(!menuRef.current?.contains(event.target as Node))setMenuOpen(false)};
|
||||
const escape=(event:KeyboardEvent)=>{if(event.key==="Escape"){event.stopPropagation();setMenuOpen(false);menuRef.current?.querySelector<HTMLButtonElement>("button")?.focus()}};
|
||||
document.addEventListener("pointerdown",outside);document.addEventListener("keydown",escape);
|
||||
return()=>{document.removeEventListener("pointerdown",outside);document.removeEventListener("keydown",escape)};
|
||||
},[menuOpen]);
|
||||
useEffect(()=>setMenuOpen(false),[active.id,computer.state]);
|
||||
const working=Boolean(computer.usingComputer);
|
||||
const running=computer.state==="running";
|
||||
const booting=computer.state==="booting";
|
||||
const run=(operation:"boot"|"restart"|"stop",handler?:()=>Promise<void>)=>{setMenuOpen(false);void action(handler||(()=>api(`/api/computer/${active.id}/${operation}`,{method:"POST",body:"{}"})))};
|
||||
return <div className="computer-actions">
|
||||
{!running?<button className="primary" disabled={busy||booting} onClick={()=>run("boot",onBoot)}>{booting?t("bootingProgress"):t("openComputer")}</button>
|
||||
:computer.controlHolder==="user"?<button className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>
|
||||
:<button className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{working?t("takeOverNow"):t("takeControl")}</button>}
|
||||
{running&&working&&<button className="outline" disabled={busy} onClick={()=>void action(()=>api(sessionId?`/api/sessions/${sessionId}/stop`:`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"}))}><Square/>{t("stopTask")}</button>}
|
||||
{computer.state!=="stopped"&&<div className="computer-power" ref={menuRef}>
|
||||
<button type="button" className="outline computer-power-trigger" disabled={busy||booting} aria-expanded={menuOpen} aria-label={t("computerPower")} title={t("computerPower")} onClick={()=>setMenuOpen(open=>!open)}><Ellipsis/></button>
|
||||
{menuOpen&&<div className="computer-power-menu">
|
||||
<button type="button" disabled={busy} onClick={()=>run("restart",onRestart)}><RefreshCw/>{t("restartComputer")}</button>
|
||||
<button type="button" className="computer-power-stop" disabled={busy} onClick={()=>run("stop",onStop)}><Square/>{t("shutDownComputer")}</button>
|
||||
{computer.mode==="team"&&<small>{t("sharedPowerHint")}</small>}
|
||||
</div>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>;onStop?:()=>Promise<void>}){const interactive=props.computer.controlHolder==="user";return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" disabled={!interactive} title={t("pasteClipboard")} onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" disabled={!interactive} title={t("copyDesktopClipboard")} onClick={props.copy}><UseAnimations animation={copy} size={18} strokeColor="var(--ink)"/></button></div>}
|
||||
function TeachDialog({bot,busy,close,start}:{bot:Bot;busy:boolean;close:()=>void;start:(goal:string)=>void}){
|
||||
const [goal,setGoal]=useState("");
|
||||
return <div className="modal-backdrop" onClick={close}><form className="dialog teach-dialog" onClick={e=>e.stopPropagation()} onSubmit={e=>{e.preventDefault();if(goal.trim())start(goal.trim())}}>
|
||||
|
|
@ -603,8 +685,12 @@ function SkillDraftCard({skill,busy,onSave,onTest,onDiscard,onEdit,onExport}:{sk
|
|||
const lines=(text:string)=>text.split("\n").map(line=>line.replace(/^\s*(?:\d+[.、)]|[-•*])\s*/,"").trim()).filter(Boolean);
|
||||
function stepsToText(steps:(PlaybookStep|string)[]|undefined){return (steps||[]).map(step=>{if(typeof step==="string")return step;return step.expect?`${step.do} → ${step.expect}`:step.do}).join("\n")}
|
||||
function textToSteps(text:string,previous:(PlaybookStep|string)[]|undefined):PlaybookStep[]{const old=(previous||[]).filter((step):step is PlaybookStep=>typeof step==="object"&&step!==null);return lines(text).map(line=>{const [doPart,...rest]=line.split(/\s*(?:→|->|=>)\s*/);const expect=rest.join(" → ").trim();const match=old.find(step=>step.do===doPart.trim());return {do:doPart.trim(),expect:expect||(match&&!rest.length?match.expect:undefined)||"",note:match?.note||""}})}
|
||||
function inputsToText(inputs:PlaybookInput[]|undefined){return (inputs||[]).map(input=>`${input.name}${input.description?`:${input.description}`:""}${input.example?`|例:${input.example}`:""}`).join("\n")}
|
||||
function textToInputs(text:string):PlaybookInput[]{return lines(text).map(line=>{const [head,...exampleParts]=line.split(/\s*[||]\s*/);const example=exampleParts.join("|").replace(/^例[::]\s*/,"").trim();const [name,...descParts]=head.split(/[::]/);return {name:name.trim(),description:descParts.join(":").trim()||undefined,example:example||undefined}}).filter(input=>input.name)}
|
||||
function inputsToText(inputs:PlaybookInput[]|undefined){
|
||||
const colon=getLocale()==="en"?": ":":";
|
||||
const exampleMark=getLocale()==="en"?" | e.g. ":"|例:";
|
||||
return (inputs||[]).map(input=>`${input.name}${input.description?`${colon}${input.description}`:""}${input.example?`${exampleMark}${input.example}`:""}`).join("\n");
|
||||
}
|
||||
function textToInputs(text:string):PlaybookInput[]{return lines(text).map(line=>{const [head,...exampleParts]=line.split(/\s*[||]\s*/);const example=exampleParts.join("|").replace(/^(?:例[::]|e\.g\.?:?)\s*/i,"").trim();const [name,...descParts]=head.split(/[::]/);return {name:name.trim(),description:descParts.join(getLocale()==="en"?": ":":").trim()||undefined,example:example||undefined}}).filter(input=>input.name)}
|
||||
function SkillEditDialog({skill,busy,close,save,test,remove,exportFile}:{skill:TaughtSkill;busy:boolean;close:()=>void;save:(name:string,playbook:Playbook)=>void;test:(name:string,playbook:Playbook)=>void;remove:()=>void;exportFile:(name:string,playbook:Playbook)=>void}){
|
||||
const p=skill.playbook;
|
||||
const [name,setName]=useState(skill.name||p.name||"");
|
||||
|
|
@ -637,7 +723,7 @@ function SkillEditDialog({skill,busy,close,save,test,remove,exportFile}:{skill:T
|
|||
function ClipboardDialog({close,paste}:{close:()=>void;paste:(text:string)=>void}){const[text,setText]=useState("");return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("pasteToRemoteComputer")}</h2><button onClick={close}><X/></button></div><p>{t("pasteRemoteHelp")}</p><textarea className="clipboard-text" autoFocus value={text} onChange={e=>setText(e.target.value)} placeholder={t("pasteTextPlaceholder")}/><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={!text} onClick={()=>paste(text)}>{t("pasteIntoVnc")}</button></div></div></div>}
|
||||
function CreateDialog({close,created}:{close:()=>void;created:(bot:Bot)=>void}){const[name,setName]=useState("");const[mode,setMode]=useState<ComputerMode>("team");const[busy,setBusy]=useState(false);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();if(!name.trim())return;setBusy(true);try{created(await api<Bot>("/api/bots",{method:"POST",body:JSON.stringify({name:name.trim(),computerMode:mode})}))}finally{setBusy(false)}}}><div className="dialog-title"><h2>{t("addBot")}</h2><button type="button" onClick={close}><X/></button></div><label>{t("name")}<input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={t("botNamePlaceholder")}/></label><div className="mode-grid"><button type="button" className={mode==="team"?"picked":""} onClick={()=>setMode("team")}><BotIcon/><strong>{t("sharedComputer")}</strong><small>{t("sharedComputerHint")}</small></button><button type="button" className={mode==="dedicated"?"picked":""} onClick={()=>setMode("dedicated")}><Computer/><strong>{t("privateComputer")}</strong><small>{t("privateComputerHint")}</small></button></div><div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()}>{t("create")}</button></div></form></div>}
|
||||
function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:(room:Room)=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState<string[]>([]);const[busy,setBusy]=useState(false);const visible=bots.filter(bot=>!bot.hidden);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();const groupName=name.trim();if(!groupName||selected.length<2)return;setBusy(true);try{created(await api<Room>("/api/rooms",{method:"POST",body:JSON.stringify({name:groupName,memberIds:selected})}))}finally{setBusy(false)}}}><div className="dialog-title"><h2>{t("addGroup")}</h2><button type="button" onClick={close}><X/></button></div><p className="dialog-lead">{t("groupDescription")}</p><label>{t("groupName")}<input autoFocus value={name} maxLength={30} onChange={e=>setName(e.target.value)} placeholder={t("groupNamePlaceholder")}/></label><fieldset className="group-picker"><legend>{t("chooseBots")}</legend>{visible.map(bot=><label key={bot.id}><input type="checkbox" checked={selected.includes(bot.id)} onChange={()=>setSelected(ids=>ids.includes(bot.id)?ids.filter(id=>id!==bot.id):[...ids,bot.id])}/><Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} online/><span>{bot.name}</span></label>)}</fieldset><div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()||selected.length<2}>{busy?t("creating"):t("createGroup")}</button></div></form></div>}
|
||||
function RoomContextMenu({context,close,onDelete}:{context:{room:Room;x:number;y:number};close:()=>void;onDelete:()=>void}){return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-160)}} onClick={e=>e.stopPropagation()}><button className="danger-item" onClick={onDelete}><UseAnimations animation={trash2} size={15} strokeColor="#ff7777"/>{t("deleteGroup")}</button><button className="context-close" onClick={close}><X/></button></div>}
|
||||
function RoomContextMenu({context,close,onDelete}:{context:{room:Room;x:number;y:number};close:()=>void;onDelete:()=>void}){return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-160)}} onClick={e=>e.stopPropagation()}><button className="danger-item" onClick={onDelete}><UseAnimations animation={trash2} size={15} strokeColor="var(--danger-soft)"/>{t("deleteGroup")}</button><button className="context-close" onClick={close}><X/></button></div>}
|
||||
function ConfirmRoomDelete({room,close,confirm}:{room:Room;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteNamed",{name:room.name})}</h2><p>{t("deleteGroupDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteGroup")}</button></div></div></div>}
|
||||
function ConfirmDelete({bot,close,confirm}:{bot:Bot;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteNamed",{name:bot.name})}</h2><p>{bot.computerMode==="dedicated"?t("deleteDedicatedBotDescription"):t("deleteSharedBotDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("delete")}</button></div></div></div>}
|
||||
|
||||
|
|
@ -659,6 +745,13 @@ function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCol
|
|||
<label>{t("workspaceName")}<input autoFocus value={draft} maxLength={40} onChange={e=>setDraft(e.target.value)}/></label>
|
||||
<label className="memory-toggle"><input type="checkbox" checked={showHidden} onChange={e=>setShowHidden(e.target.checked)}/> {t("showHiddenBots")}</label>
|
||||
<label className="memory-toggle"><input type="checkbox" checked={rightCollapsed} onChange={e=>setRightCollapsed(e.target.checked)}/> {t("collapseRightSidebar")}</label>
|
||||
<label>{t("language")}
|
||||
<select value={getLocale()} onChange={e=>setLocale(e.target.value==="en"?"en":"zh-TW")}>
|
||||
<option value="zh-TW">{t("languageZh")}</option>
|
||||
<option value="en">{t("languageEn")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="dialog-lead">{t("languageHint")}</p>
|
||||
<p className="dialog-lead">{t("workspaceSettingsHint")}</p>
|
||||
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div>
|
||||
</form></div>
|
||||
|
|
@ -674,7 +767,7 @@ function ModelSettingsDialog({close}:{close:()=>void}){
|
|||
const[busy,setBusy]=useState(false);
|
||||
const[error,setError]=useState("");
|
||||
const current=settings?.providers.find(item=>item.id===provider);
|
||||
useEffect(()=>{api<WorkspaceSettings>("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[]);
|
||||
useEffect(()=>{api<WorkspaceSettings>("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[]);
|
||||
async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
|
||||
if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
|
||||
try{
|
||||
|
|
@ -698,7 +791,7 @@ function ModelSettingsDialog({close}:{close:()=>void}){
|
|||
if(info?.defaultModel)setModelId(info.defaultModel);
|
||||
void loadModels(id,info?.defaultBaseUrl||"");
|
||||
}
|
||||
return <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{e.preventDefault();setBusy(true);setError("");try{await api("/api/workspace/settings",{method:"PATCH",body:JSON.stringify({provider,modelId:modelId.trim(),baseUrl:baseUrl.trim()||null,apiKey:clearKey?"":apiKey.trim()||null,clearApiKey:clearKey})});close()}catch(e){setError(e instanceof Error?e.message:t("settingsFailed"))}finally{setBusy(false)}}}>
|
||||
return <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{e.preventDefault();setBusy(true);setError("");try{await api("/api/workspace/settings",{method:"PATCH",body:JSON.stringify({provider,modelId:modelId.trim(),baseUrl:baseUrl.trim()||null,apiKey:clearKey?"":apiKey.trim()||null,clearApiKey:clearKey})});close()}catch(e){setError(e instanceof Error?localizeError(e.message):t("settingsFailed"))}finally{setBusy(false)}}}>
|
||||
<div className="dialog-title"><h2>{t("modelSettings")}</h2><button type="button" onClick={close}><X/></button></div>
|
||||
<p className="dialog-lead">{t("modelSettingsHint")}</p>
|
||||
<fieldset className="provider-fieldset"><legend>{t("modelProvider")}</legend>
|
||||
|
|
@ -741,6 +834,8 @@ function HelpDialog({close}:{close:()=>void}){
|
|||
<section><h3>{t("helpMcpTitle")}</h3><p>{t("helpMcp")}</p></section>
|
||||
<section><h3>{t("helpSkillsTitle")}</h3><p>{t("helpSkills")}</p></section>
|
||||
<section><h3>{t("helpAttachTitle")}</h3><p>{t("helpAttach")}</p></section>
|
||||
<section><h3>{t("helpVoiceTitle")}</h3><p>{t("helpVoice")}</p></section>
|
||||
<section><h3>{t("helpLanguageTitle")}</h3><p>{t("helpLanguage")}</p></section>
|
||||
<section><h3>{t("helpShortcutsTitle")}</h3><p>{t("helpShortcuts")}</p></section>
|
||||
</div>
|
||||
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
|
||||
|
|
@ -755,4 +850,15 @@ function FeedbackDialog({close}:{close:()=>void}){
|
|||
</div></div>
|
||||
}
|
||||
|
||||
function LoginScreen({authenticated}:{authenticated:()=>void}){const[token,setToken]=useState("");const[busy,setBusy]=useState(false);const[error,setError]=useState("");return <main className="login-screen"><form className="dialog compact login-dialog" onSubmit={async e=>{e.preventDefault();if(!token)return;setBusy(true);setError("");try{await api("/api/session",{method:"POST",body:JSON.stringify({token})});authenticated()}catch(err){setError(err instanceof Error?err.message:t("loginFailed"))}finally{setBusy(false)}}}><Avatar name="L" size={58}/><h1>{t("loginTitle")}</h1><p>{t("loginDescription")}</p><label>{t("accessToken")}<input type="password" autoFocus autoComplete="current-password" value={token} onChange={e=>setToken(e.target.value)}/></label>{error&&<div className="login-error">{error}</div>}<button className="primary" disabled={busy||!token}>{busy?t("verifying"):t("login")}</button></form></main>}
|
||||
function LoginScreen({authenticated}:{authenticated:()=>void}){
|
||||
useLocale();
|
||||
const[token,setToken]=useState("");const[busy,setBusy]=useState(false);const[error,setError]=useState("");
|
||||
return <main className="login-screen">
|
||||
<div className="login-lang" role="group" aria-label={t("language")}>
|
||||
<button type="button" aria-pressed={getLocale()==="zh-TW"} className={getLocale()==="zh-TW"?"picked":""} onClick={()=>setLocale("zh-TW")}>{t("languageZh")}</button>
|
||||
<button type="button" aria-pressed={getLocale()==="en"} className={getLocale()==="en"?"picked":""} onClick={()=>setLocale("en")}>{t("languageEn")}</button>
|
||||
</div>
|
||||
<form className="dialog compact login-dialog" onSubmit={async e=>{e.preventDefault();if(!token)return;setBusy(true);setError("");try{await api("/api/session",{method:"POST",body:JSON.stringify({token})});authenticated()}catch(err){setError(err instanceof Error?localizeError(err.message):t("loginFailed"))}finally{setBusy(false)}}}>
|
||||
<Avatar name="L" size={58}/><h1>{t("loginTitle")}</h1><p>{t("loginDescription")}</p><label>{t("accessToken")}<input type="password" autoFocus autoComplete="current-password" value={token} onChange={e=>setToken(e.target.value)}/></label>{error&&<div className="login-error">{error}</div>}<button className="primary" disabled={busy||!token}>{busy?t("verifying"):t("login")}</button>
|
||||
</form></main>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,21 +9,21 @@
|
|||
.avatar-editor{display:grid;justify-items:center;gap:5px;padding:18px 0 2px}
|
||||
.avatar-editor strong{margin-top:7px}
|
||||
.avatar-editor .avatar.blobatar{margin-bottom:2px}
|
||||
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side,#0b0b0c);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
|
||||
.bot-row .avatar-wrap::after{content:none;position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
|
||||
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side);border-radius:50%;background:var(--online);box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
|
||||
.bot-row .avatar-wrap::after{content:none;position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:var(--online);box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
|
||||
.avatar.blobatar.thinking{animation:avatar-rainbow-glow 1.8s linear infinite}
|
||||
.avatar-wrap{transition:transform .16s ease}
|
||||
.avatar-stack{position:relative;display:block;flex:0 0 auto}
|
||||
.avatar-stack .stack-item{position:absolute;top:0}
|
||||
.avatar-stack .stack-item .avatar{box-shadow:none;filter:drop-shadow(2px 0 0 var(--side,#0b0b0c)) drop-shadow(-2px 0 0 var(--side,#0b0b0c)) drop-shadow(0 2px 0 var(--side,#0b0b0c)) drop-shadow(0 -2px 0 var(--side,#0b0b0c))}
|
||||
.avatar-stack .stack-item .avatar{box-shadow:none;filter:drop-shadow(2px 0 0 var(--side)) drop-shadow(-2px 0 0 var(--side)) drop-shadow(0 2px 0 var(--side)) drop-shadow(0 -2px 0 var(--side))}
|
||||
.avatar-stack .stack-item .avatar.thinking{filter:none;animation:avatar-rainbow-glow 1.8s linear infinite}
|
||||
.stack-extra{position:absolute;top:0;display:grid;place-items:center;border-radius:50%;background:#2a2a2e;color:var(--muted);font-size:11px;font-weight:650;box-shadow:0 0 0 2px var(--side,#0b0b0c)}
|
||||
.stack-extra{position:absolute;top:0;display:grid;place-items:center;border-radius:50%;background:#2a2a2e;color:var(--muted);font-size:11px;font-weight:650;box-shadow:0 0 0 2px var(--side)}
|
||||
.topbar .avatar-stack{margin-right:2px}
|
||||
.avatar-stack{isolation:isolate}
|
||||
.avatar-stack .stack-item{transition:transform .16s ease}
|
||||
.avatar-stack .stack-item:hover{transform:translateY(-2px);z-index:8!important}
|
||||
.avatar-stack .stack-item .avatar{filter:drop-shadow(2px 0 0 var(--main,#0d0d0e)) drop-shadow(-2px 0 0 var(--main,#0d0d0e)) drop-shadow(0 2px 0 var(--main,#0d0d0e)) drop-shadow(0 -2px 0 var(--main,#0d0d0e))}
|
||||
.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main,#0d0d0e)}
|
||||
.avatar-stack .stack-item .avatar{filter:drop-shadow(2px 0 0 var(--main)) drop-shadow(-2px 0 0 var(--main)) drop-shadow(0 2px 0 var(--main)) drop-shadow(0 -2px 0 var(--main))}
|
||||
.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main)}
|
||||
.avatar-stack .stack-item .avatar.thinking{filter:none}
|
||||
/* Keep avatars independent of message and toolbar dimensions. */
|
||||
.avatar.blobatar{min-width:var(--avatar-size);max-width:var(--avatar-size);min-height:var(--avatar-size);padding:0;line-height:0;vertical-align:middle}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
export const VOICE_SAMPLE_RATE = 24000;
|
||||
|
||||
export function floatToPcm16(input: Float32Array): Uint8Array {
|
||||
const pcm = new Int16Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, input[i] ?? 0));
|
||||
pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
}
|
||||
return new Uint8Array(pcm.buffer);
|
||||
}
|
||||
|
||||
export function pcm16ToFloat(bytes: ArrayBuffer): Float32Array {
|
||||
const pcm = new Int16Array(bytes.byteLength % 2 === 0 ? bytes : bytes.slice(0, bytes.byteLength - 1));
|
||||
const out = new Float32Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i += 1) {
|
||||
out[i] = (pcm[i] ?? 0) / 32768;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resample(input: Float32Array, fromRate: number, toRate: number): Float32Array {
|
||||
if (fromRate === toRate || input.length === 0) return input;
|
||||
const ratio = fromRate / toRate;
|
||||
const length = Math.max(1, Math.round(input.length / ratio));
|
||||
const out = new Float32Array(length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const src = i * ratio;
|
||||
const left = Math.floor(src);
|
||||
const frac = src - left;
|
||||
const a = input[left] ?? 0;
|
||||
const b = input[Math.min(left + 1, input.length - 1)] ?? 0;
|
||||
out[i] = a + (b - a) * frac;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const WORKLET = `
|
||||
class CaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const channel = inputs[0] && inputs[0][0];
|
||||
if (channel && channel.length) this.port.postMessage(channel);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor("lazyboy-capture", CaptureProcessor);
|
||||
`;
|
||||
|
||||
// One frame per 128-sample worklet callback would be ~375 websocket messages a
|
||||
// second; batching to 40 ms keeps latency low without flooding the provider.
|
||||
export const CAPTURE_CHUNK_SAMPLES = VOICE_SAMPLE_RATE / 25;
|
||||
// Cushion so a late chunk does not have to start in the past and click.
|
||||
const PLAYBACK_LEAD_SECONDS = 0.08;
|
||||
|
||||
export type CallAudioHandlers = {
|
||||
onCapture: (pcm: Uint8Array) => void;
|
||||
onError: (message: string) => void;
|
||||
onPlaybackChange?: (playing: boolean) => void;
|
||||
};
|
||||
|
||||
export class CallAudio {
|
||||
private stopped = false;
|
||||
private context: AudioContext | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private worklet: AudioWorkletNode | null = null;
|
||||
private monitor: GainNode | null = null;
|
||||
private nextTime = 0;
|
||||
private playing: AudioBufferSourceNode[] = [];
|
||||
private playbackActive = false;
|
||||
private pending: Float32Array[] = [];
|
||||
private pendingLength = 0;
|
||||
private carry: Uint8Array | null = null;
|
||||
private handlers: CallAudioHandlers;
|
||||
|
||||
constructor(handlers: CallAudioHandlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
private queueCapture(samples: Float32Array): void {
|
||||
if (samples.length) {
|
||||
this.pending.push(samples);
|
||||
this.pendingLength += samples.length;
|
||||
}
|
||||
while (this.pendingLength >= CAPTURE_CHUNK_SAMPLES) {
|
||||
const chunk = new Float32Array(CAPTURE_CHUNK_SAMPLES);
|
||||
let filled = 0;
|
||||
while (filled < CAPTURE_CHUNK_SAMPLES) {
|
||||
const head = this.pending[0] as Float32Array;
|
||||
const take = Math.min(head.length, CAPTURE_CHUNK_SAMPLES - filled);
|
||||
chunk.set(head.subarray(0, take), filled);
|
||||
filled += take;
|
||||
if (take === head.length) this.pending.shift();
|
||||
else this.pending[0] = head.subarray(take);
|
||||
}
|
||||
this.pendingLength -= CAPTURE_CHUNK_SAMPLES;
|
||||
this.handlers.onCapture(floatToPcm16(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
private notifyPlayback(): void {
|
||||
const active = this.playing.length > 0;
|
||||
if (active === this.playbackActive) return;
|
||||
this.playbackActive = active;
|
||||
this.handlers.onPlaybackChange?.(active);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
if (this.stopped) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
this.stream = stream;
|
||||
const context = new AudioContext();
|
||||
this.context = context;
|
||||
if (context.state === "suspended") await context.resume();
|
||||
const blob = new Blob([WORKLET], { type: "application/javascript" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
await context.audioWorklet.addModule(url);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
if (this.stopped) return;
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const worklet = new AudioWorkletNode(context, "lazyboy-capture");
|
||||
worklet.port.onmessage = (event) => {
|
||||
const samples = event.data as Float32Array;
|
||||
this.queueCapture(resample(samples, context.sampleRate, VOICE_SAMPLE_RATE));
|
||||
};
|
||||
worklet.onprocessorerror = () => this.handlers.onError("capture stopped");
|
||||
// Muted sink: the processor emits no output, and a zero gain guarantees the
|
||||
// microphone can never leak back into the speakers.
|
||||
const monitor = context.createGain();
|
||||
monitor.gain.value = 0;
|
||||
source.connect(worklet);
|
||||
worklet.connect(monitor);
|
||||
monitor.connect(context.destination);
|
||||
this.worklet = worklet;
|
||||
this.monitor = monitor;
|
||||
this.nextTime = context.currentTime;
|
||||
}
|
||||
|
||||
play(pcm: ArrayBuffer): void {
|
||||
const context = this.context;
|
||||
if (!context) return;
|
||||
// PCM16 frames can be split mid-sample across websocket messages; dropping
|
||||
// the odd tail byte would shift every later sample and turn speech to noise.
|
||||
let bytes = new Uint8Array(pcm);
|
||||
if (this.carry) {
|
||||
const joined = new Uint8Array(this.carry.length + bytes.length);
|
||||
joined.set(this.carry, 0);
|
||||
joined.set(bytes, this.carry.length);
|
||||
bytes = joined;
|
||||
this.carry = null;
|
||||
}
|
||||
if (bytes.length % 2 === 1) {
|
||||
this.carry = bytes.slice(bytes.length - 1);
|
||||
bytes = bytes.subarray(0, bytes.length - 1);
|
||||
}
|
||||
if (!bytes.length) return;
|
||||
const aligned = bytes.slice().buffer;
|
||||
const samples = resample(pcm16ToFloat(aligned), VOICE_SAMPLE_RATE, context.sampleRate);
|
||||
if (!samples.length) return;
|
||||
try {
|
||||
const buffer = context.createBuffer(1, samples.length, context.sampleRate);
|
||||
buffer.getChannelData(0).set(samples);
|
||||
const node = context.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
node.connect(context.destination);
|
||||
if (this.nextTime < context.currentTime) this.nextTime = context.currentTime + PLAYBACK_LEAD_SECONDS;
|
||||
const startAt = this.nextTime;
|
||||
node.start(startAt);
|
||||
this.nextTime = startAt + buffer.duration;
|
||||
this.playing.push(node);
|
||||
node.onended = () => {
|
||||
this.playing = this.playing.filter((item) => item !== node);
|
||||
this.notifyPlayback();
|
||||
};
|
||||
this.notifyPlayback();
|
||||
} catch {
|
||||
this.handlers.onError("playback failed");
|
||||
}
|
||||
}
|
||||
|
||||
interrupt(): void {
|
||||
for (const node of this.playing) {
|
||||
node.onended = null;
|
||||
try { node.stop(); } catch { /* already stopped */ }
|
||||
}
|
||||
this.playing = [];
|
||||
this.carry = null;
|
||||
if (this.context) this.nextTime = this.context.currentTime;
|
||||
this.notifyPlayback();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.interrupt();
|
||||
this.pending = [];
|
||||
this.pendingLength = 0;
|
||||
this.worklet?.disconnect();
|
||||
this.worklet = null;
|
||||
this.monitor?.disconnect();
|
||||
this.monitor = null;
|
||||
this.stream?.getTracks().forEach((track) => track.stop());
|
||||
this.stream = null;
|
||||
void this.context?.close();
|
||||
this.context = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.call-overlay{position:absolute;inset:0;z-index:40;display:grid;place-items:center;background:rgba(5,5,6,.82);padding:24px}
|
||||
.call-card{width:min(420px,100%);display:flex;flex-direction:column;align-items:center;gap:10px;padding:28px 24px 22px;border:1px solid var(--border);border-radius:28px;background:var(--elevated);box-shadow:0 30px 80px rgba(0,0,0,.55);text-align:center}
|
||||
.call-kicker{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
|
||||
.call-name{margin-top:4px;font-size:22px}
|
||||
.call-phase{min-height:1.4em;color:var(--accent);font-size:14px}
|
||||
.call-phase.working{color:#ffe08a}
|
||||
.call-caption{min-height:3.2em;margin:4px 0 0;color:var(--muted);font-size:14.5px;line-height:1.5}
|
||||
.call-caption.assistant{color:var(--ink)}
|
||||
.call-error{margin:0;color:var(--danger);font-size:13px}
|
||||
.call-time{color:var(--faint);font-variant-numeric:tabular-nums;font-size:13px}
|
||||
.call-actions{display:flex;gap:10px;margin-top:8px}
|
||||
.call-interrupt,.call-hangup{min-width:108px;height:40px;border:0;border-radius:999px;cursor:pointer}
|
||||
.call-interrupt{background:var(--surface);color:var(--ink)}
|
||||
.call-hangup{background:var(--danger);color:#fff;font-weight:600}
|
||||
.call-takeover{margin-top:8px}
|
||||
.call-hint{margin:8px 0 0;color:var(--faint);font-size:12px}
|
||||
.top-tool-button.call-active{background:rgba(62,197,168,.16);color:var(--accent)}
|
||||
|
||||
.call-entry{display:inline-flex;border-radius:9px}
|
||||
.call-entry:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.call-entry button:disabled{pointer-events:none}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Avatar } from "./avatar";
|
||||
import { CallAudio } from "./call-audio";
|
||||
import { t } from "./i18n";
|
||||
import type { AvatarShape, Bot } from "./types";
|
||||
|
||||
export type CallPhase = "connecting" | "listening" | "speaking" | "working";
|
||||
|
||||
type ComputerEvent = { status?: string; step?: string | null; takeover?: boolean };
|
||||
type ServerEvent =
|
||||
| { type: "ready"; callId?: string; voice?: string }
|
||||
| { type: "transcript"; role: "user" | "assistant"; text: string; final?: boolean }
|
||||
| { type: "speech"; state: "started" | "stopped" }
|
||||
| { type: "computer"; status?: string; step?: string | null; takeover?: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
function wsUrl(sessionId: string): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}/api/sessions/${sessionId}/call`;
|
||||
}
|
||||
|
||||
function phaseLabel(phase: CallPhase, computer: ComputerEvent | null): string {
|
||||
if (computer?.takeover) return t("takeOverToContinue");
|
||||
if (phase === "connecting") return t("callConnecting");
|
||||
if (phase === "speaking") return t("speaking");
|
||||
if (phase === "working") return t("workingOnComputer");
|
||||
return t("listening");
|
||||
}
|
||||
|
||||
function computerIsBusy(computer: ComputerEvent | null): boolean {
|
||||
return computer?.status === "running" || computer?.status === "queued" || Boolean(computer?.takeover);
|
||||
}
|
||||
|
||||
export function PhoneIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M6.5 3.8c.5-1 1.6-1.4 2.6-1l2 1c.8.4 1.2 1.3 1 2.2l-.6 2.2a2 2 0 0 0 .6 1.9l3.8 3.8c.5.5 1.3.7 1.9.6l2.2-.6c.9-.2 1.8.2 2.2 1l1 2c.4 1 .1 2.1-.9 2.6-2.3 1.2-7.2 1-11.6-3.4S4.1 7.6 5.3 5.3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallOverlay({
|
||||
bot,
|
||||
sessionId,
|
||||
takeover,
|
||||
onHangUp,
|
||||
onTakeOver,
|
||||
}: {
|
||||
bot: Bot;
|
||||
sessionId: string;
|
||||
takeover: boolean;
|
||||
onHangUp: () => void;
|
||||
onTakeOver: () => void;
|
||||
}) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
const [caption, setCaption] = useState<{ role: "user" | "assistant"; text: string } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [computer, setComputer] = useState<ComputerEvent | null>(null);
|
||||
const audioRef = useRef<CallAudio | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const hanging = useRef(false);
|
||||
|
||||
function hangUp() {
|
||||
hanging.current = true;
|
||||
audioRef.current?.stop();
|
||||
audioRef.current = null;
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
onHangUp();
|
||||
}
|
||||
|
||||
function interrupt() {
|
||||
audioRef.current?.interrupt();
|
||||
// Stopping local playback alone lets the model keep streaming the rest of
|
||||
// its answer, so the provider has to be told to abandon the response too.
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "interrupt" }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
hanging.current = false;
|
||||
let disposed = false;
|
||||
const started = Date.now();
|
||||
const tick = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000);
|
||||
const audio = new CallAudio({
|
||||
onCapture: (pcm) => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) socketRef.current.send(pcm);
|
||||
},
|
||||
onError: () => setError(t("callAudioFailed")),
|
||||
onPlaybackChange: setSpeaking,
|
||||
});
|
||||
audioRef.current = audio;
|
||||
const socket = new WebSocket(wsUrl(sessionId));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
socket.onopen = () => {
|
||||
void audio.start().catch((err) => {
|
||||
if (disposed) return;
|
||||
audio.stop();
|
||||
socket.close();
|
||||
setError(err instanceof Error && /NotAllowedError|PermissionDenied/.test(err.name + err.message) ? t("micDenied") : t("micFailed"));
|
||||
});
|
||||
setConnected(true);
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== "string") {
|
||||
audio.play(event.data as ArrayBuffer);
|
||||
return;
|
||||
}
|
||||
let payload: ServerEvent;
|
||||
try { payload = JSON.parse(event.data) as ServerEvent; } catch { return; }
|
||||
if (payload.type === "speech") {
|
||||
if (payload.state === "started") audio.interrupt();
|
||||
return;
|
||||
}
|
||||
if (payload.type === "transcript") {
|
||||
setCaption({ role: payload.role, text: payload.text });
|
||||
return;
|
||||
}
|
||||
if (payload.type === "computer") {
|
||||
setComputer(payload);
|
||||
return;
|
||||
}
|
||||
if (payload.type === "error") setError(payload.message);
|
||||
};
|
||||
socket.onerror = () => { if (!disposed && !hanging.current) setError(t("callFailed")); };
|
||||
socket.onclose = () => {
|
||||
audio.stop();
|
||||
if (!disposed && !hanging.current) setError((previous) => previous || t("callFailed"));
|
||||
};
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") { event.preventDefault(); hangUp(); }
|
||||
if (event.key === " " && !event.repeat && event.target === document.body) {
|
||||
event.preventDefault();
|
||||
interrupt();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
disposed = true;
|
||||
hanging.current = true;
|
||||
window.clearInterval(tick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
audio.stop();
|
||||
socket.close();
|
||||
};
|
||||
}, [sessionId, bot.id]);
|
||||
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = String(elapsed % 60).padStart(2, "0");
|
||||
const showTakeover = takeover || Boolean(computer?.takeover);
|
||||
const phase: CallPhase = !connected
|
||||
? "connecting"
|
||||
: speaking
|
||||
? "speaking"
|
||||
: computerIsBusy(computer)
|
||||
? "working"
|
||||
: "listening";
|
||||
|
||||
return (
|
||||
<div className="call-overlay" role="dialog" aria-label={t("call")}>
|
||||
<div className="call-card" data-testid="call-view">
|
||||
<div className="call-kicker">{t("call")}</div>
|
||||
<Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape as AvatarShape} active online size={72} />
|
||||
<strong className="call-name">{bot.name}</strong>
|
||||
<div className={`call-phase ${phase}`}>{phaseLabel(phase, computer)}</div>
|
||||
<p className={`call-caption ${caption ? caption.role : ""}`}>{caption?.text || t("callPrompt")}</p>
|
||||
{error ? <p className="call-error">{error}</p> : null}
|
||||
<div className="call-time">{minutes}:{seconds}</div>
|
||||
<div className="call-actions">
|
||||
<button type="button" className="call-interrupt" onClick={interrupt}>{t("interrupt")}</button>
|
||||
<button type="button" className="call-hangup" onClick={hangUp}>{t("hangUp")}</button>
|
||||
</div>
|
||||
{showTakeover ? (
|
||||
<button type="button" className="primary call-takeover" onClick={onTakeOver}>{t("takeOverNow")}</button>
|
||||
) : null}
|
||||
<p className="call-hint">{t("callShortcuts")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
.message>.message-body,.message-stack>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
|
||||
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
|
||||
.message.user{justify-content:flex-end}
|
||||
.message.user>.message-body{background:var(--cream);color:#1a1a1a}
|
||||
.message.user>.message-body{background:var(--cream);color:var(--on-cream)}
|
||||
.message.assistant>.message-body,.message-stack>.message-body{background:#19191c}
|
||||
.message-stack{display:flex;flex-direction:column;align-items:flex-start;max-width:76%;min-width:0}
|
||||
.message-stack>.message-body{max-width:100%}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
.copy-msg svg{flex:0 0 14px}
|
||||
.copy-msg:hover{background:rgba(255,255,255,.07);color:var(--ink)}
|
||||
.copy-msg.copied,.copy-msg.copied:hover{color:var(--accent);background:transparent;cursor:default}
|
||||
.composer{position:absolute;left:max(34px,5vw);right:max(34px,5vw);bottom:22px;display:flex;align-items:flex-end;gap:8px;border:1px solid var(--border);background:#121214;border-radius:26px;padding:8px;box-shadow:0 16px 50px rgba(0,0,0,.22)}
|
||||
.composer{position:absolute;left:max(34px,5vw);right:max(34px,5vw);bottom:22px;display:flex;align-items:flex-end;gap:8px;border:1px solid var(--border);background:var(--elevated);border-radius:26px;padding:8px;box-shadow:0 16px 50px rgba(0,0,0,.22)}
|
||||
.composer textarea{flex:1;min-height:42px;max-height:140px;resize:none;border:0;outline:0;background:transparent;color:var(--ink);padding:11px 4px}
|
||||
.composer-plus{background:transparent;color:var(--muted)}
|
||||
.composer svg{width:18px}
|
||||
|
|
@ -30,10 +30,15 @@
|
|||
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-height:62px;align-items:center;padding:9px 10px;transform:none}
|
||||
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
|
||||
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
|
||||
.slash-suggestions{position:absolute;left:58px;right:58px;bottom:calc(100% + 8px);z-index:8;display:grid;gap:2px;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35)}
|
||||
.slash-suggestions button{display:flex;align-items:baseline;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:9px;background:transparent;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.slash-suggestions button:hover,.slash-suggestions button:focus-visible{background:rgba(255,255,255,.08);outline:0}
|
||||
.slash-suggestions strong{min-width:110px;color:var(--accent);font-weight:650}
|
||||
.slash-suggestions span{overflow:hidden;color:var(--muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}
|
||||
.composer-files{display:flex;flex-wrap:wrap;gap:8px;flex:1 0 100%;order:-1;padding:2px 8px 10px 46px}
|
||||
.file-card{display:flex;align-items:center;gap:8px;max-width:min(240px,100%);height:48px;padding:2px 8px 2px 2px;border:1px solid #2a2a2a;border-radius:14px;background:#1a1a1a;color:var(--ink)}
|
||||
.file-card{display:flex;align-items:center;gap:8px;max-width:min(240px,100%);height:48px;padding:2px 8px 2px 2px;border:1px solid var(--border);border-radius:14px;background:var(--surface);color:var(--ink)}
|
||||
.file-card-thumb,.file-card-badge{flex:0 0 44px;width:44px;height:44px;border-radius:11px;object-fit:cover}
|
||||
.file-card-badge{display:grid;place-items:center;background:#2a2a2a;color:#c8c8c8;font-size:10px;font-weight:700;letter-spacing:.04em}
|
||||
.file-card-badge{display:grid;place-items:center;background:var(--hairline);color:var(--muted);font-size:10px;font-weight:700;letter-spacing:.04em}
|
||||
.file-card-meta{display:grid;min-width:0;flex:1}
|
||||
.file-card-meta strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600}
|
||||
.file-card-meta small{color:var(--muted);font-size:11px}
|
||||
|
|
@ -44,7 +49,7 @@
|
|||
.message.with-files .msg-attachments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:min(420px,82%)}
|
||||
.message.with-files .message-body,.message.with-files .message-stack{max-width:82%}
|
||||
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
|
||||
.stop-send{background:#f2f2f2;color:#151515}
|
||||
.stop-send{background:var(--cream);color:var(--on-send)}
|
||||
.stop-send svg{width:14px;height:14px;fill:currentColor}
|
||||
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
|
||||
.message{position:relative;padding-right:28px}
|
||||
|
|
@ -65,3 +70,6 @@
|
|||
.message.assistant.spoken>.message-body,.message.assistant.spoken>.message-stack{grid-column:2;max-width:82%;min-width:0}
|
||||
.message.assistant.spoken .message-stack>.message-body{max-width:100%;line-height:1.52;border-radius:6px 18px 18px 18px}
|
||||
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}
|
||||
|
||||
.slash-suggestions button[aria-selected="true"]{background:rgba(255,255,255,.08)}
|
||||
.slash-suggestions small{flex-shrink:0;color:var(--muted);font-size:11px}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@
|
|||
.state-dot.running{background:var(--success)}
|
||||
.state-dot.booting{background:#f5a03c;animation:pulse 1s infinite}
|
||||
.state-dot.error{background:var(--danger)}
|
||||
.desktop-frame{display:block;width:100%;height:100%;border:0;background:#101012}
|
||||
.desktop-frame{display:block;width:100%;height:100%;border:0;background:var(--inset)}
|
||||
.empty-computer{height:100%;display:grid;place-content:center;justify-items:center;gap:7px;color:var(--muted);text-align:center}
|
||||
.empty-computer svg{width:28px}
|
||||
.empty-computer span{font-size:12px}
|
||||
.computer-caption{display:flex;align-items:center;justify-content:space-between;padding:14px 0;color:var(--muted)}
|
||||
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid #171719;padding:16px 0}
|
||||
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--hairline);padding:16px 0}
|
||||
.computer-overlay{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:rgba(4,4,5,.98)}
|
||||
.computer-overlay>header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid var(--line)}
|
||||
.computer-overlay>header>div{display:flex;align-items:center;gap:10px}
|
||||
.computer-overlay .avatar{width:30px;height:30px}
|
||||
.control-badge{padding:5px 10px;border-radius:999px;background:rgba(48,162,75,.14);color:var(--success);font-size:13px}
|
||||
.overlay-screen{flex:1;min-height:0;padding:20px;display:flex;justify-content:center}
|
||||
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:#0d0d0e}
|
||||
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:#2a1717;color:#fca5a5;padding:9px 15px;border-radius:9px}
|
||||
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
|
||||
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px}
|
||||
.computer-toggle{background:var(--surface)}
|
||||
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
|
||||
.computer-part.hidden-part{display:none}
|
||||
|
|
@ -27,11 +27,11 @@
|
|||
.computer-part .preview{position:relative;flex:0 1 auto;width:100%;height:auto;min-height:0;max-height:min(52vh,380px);aspect-ratio:16 / 10}
|
||||
.computer-part .preview .desktop-frame{display:block;aspect-ratio:16 / 10}
|
||||
.overlay-screen .overlay-desktop{position:relative;width:min(100%,1440px);height:100%}
|
||||
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:#0d0d0e}
|
||||
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
|
||||
.computer-hud{position:absolute;top:10px;left:10px;z-index:3;display:grid;justify-items:center;gap:6px;padding:10px 12px 8px;border-radius:16px;background:rgba(10,10,12,.72);backdrop-filter:blur(8px);pointer-events:none;box-shadow:0 10px 30px rgba(0,0,0,.35)}
|
||||
.computer-hud .avatar.blobatar.thinking{animation:computer-hud-bob 1.6s ease-in-out infinite,avatar-rainbow-glow 1.8s linear infinite}
|
||||
.computer-hud-label{font-size:12px;letter-spacing:.02em;line-height:1.3;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
|
||||
.empty-computer.is-waiting{min-height:100%;background:#101012}
|
||||
.empty-computer.is-waiting{min-height:100%;background:var(--inset)}
|
||||
.computer-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;flex-wrap:wrap}
|
||||
.computer-actions .restart-computer{font-size:12px;padding-inline:10px;color:var(--muted)}
|
||||
.computer-actions .restart-computer svg{width:14px;height:14px}
|
||||
|
|
@ -55,3 +55,16 @@
|
|||
.computer-hud-label{color:#c9ddd5;background:none;animation:none;white-space:normal;text-align:center;max-width:90%;line-height:1.5}
|
||||
|
||||
.clipboard-status{margin:0;min-height:1.4em;font-size:12px;line-height:1.4;color:var(--muted)}
|
||||
|
||||
/* Keep control actions prominent and group less frequent power actions. */
|
||||
.control-bar>.computer-actions{justify-content:flex-start}
|
||||
.computer-power{position:relative;flex-shrink:0;margin-left:auto}
|
||||
.computer-power-trigger{padding:8px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.computer-power-trigger svg{width:18px;height:18px}
|
||||
.computer-power-menu{position:absolute;right:0;bottom:calc(100% + 8px);z-index:40;width:180px;padding:6px;border:1px solid var(--line);border-radius:12px;background:var(--surface);box-shadow:0 8px 28px #0005}
|
||||
.computer-power-menu button{display:flex;align-items:center;gap:9px;width:100%;padding:10px;border:0;border-radius:7px;background:transparent;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.computer-power-menu button:hover{background:var(--surface-hover)}
|
||||
.computer-power-menu svg{width:16px;height:16px}
|
||||
.computer-power-menu .computer-power-stop{color:var(--danger-soft)}
|
||||
.computer-power-menu small{display:block;padding:8px 10px 6px;border-top:1px solid var(--line);color:var(--muted);font-size:11px;line-height:1.6}
|
||||
.computer-overlay .computer-power-menu{top:calc(100% + 8px);bottom:auto}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,94 @@
|
|||
import { useSyncExternalStore, type ReactNode } from "react";
|
||||
import { en } from "./locales/en";
|
||||
import { zhTW } from "./locales/zh-TW";
|
||||
|
||||
export const supportedLocales = ["zh-TW"] as const;
|
||||
export const supportedLocales = ["zh-TW", "en"] as const;
|
||||
export type Locale = (typeof supportedLocales)[number];
|
||||
|
||||
const messages = { "zh-TW": zhTW } as const;
|
||||
const messages = { "zh-TW": zhTW, en } as const;
|
||||
const LOCALE_STORE = "lazyboy.locale";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export const locale: Locale = "zh-TW";
|
||||
export type MessageKey = keyof typeof zhTW;
|
||||
export type MessageParams = Record<string, string | number>;
|
||||
|
||||
function isLocale(value: string): value is Locale {
|
||||
return (supportedLocales as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function readStoredLocale(): Locale {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCALE_STORE);
|
||||
if (raw && isLocale(raw)) return raw;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return "zh-TW";
|
||||
}
|
||||
|
||||
let current: Locale = "zh-TW";
|
||||
try {
|
||||
current = readStoredLocale();
|
||||
} catch {
|
||||
current = "zh-TW";
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function htmlLang(locale: Locale = current): string {
|
||||
return locale === "en" ? "en" : "zh-Hant";
|
||||
}
|
||||
|
||||
export function dateLocale(locale: Locale = current): string {
|
||||
return locale === "en" ? "en" : "zh-TW";
|
||||
}
|
||||
|
||||
export function listJoin(items: string[]): string {
|
||||
return items.join(current === "en" ? ", " : "、");
|
||||
}
|
||||
|
||||
function applyDocumentLang(locale: Locale) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = htmlLang(locale);
|
||||
}
|
||||
|
||||
applyDocumentLang(current);
|
||||
|
||||
export function setLocale(next: Locale) {
|
||||
if (next === current) return;
|
||||
current = next;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORE, next);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
applyDocumentLang(next);
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function subscribeLocale(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function t(key: MessageKey, params?: MessageParams): string {
|
||||
const message: string = messages[locale][key];
|
||||
const table = messages[current];
|
||||
const message: string = table[key] ?? messages["zh-TW"][key];
|
||||
if (!params) return message;
|
||||
return message.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
return useSyncExternalStore(subscribeLocale, getLocale, getLocale);
|
||||
}
|
||||
|
||||
export function LocaleRoot({ children }: { children: ReactNode }) {
|
||||
useLocale();
|
||||
return children;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,457 @@
|
|||
import type { zhTW } from "./zh-TW";
|
||||
|
||||
/** English UI copy. Keys must stay in lockstep with zh-TW. */
|
||||
export const en: { [K in keyof typeof zhTW]: string } = {
|
||||
search: "Search",
|
||||
sharedComputer: "Shared computer",
|
||||
privateComputer: "Private computer",
|
||||
stopped: "Stopped",
|
||||
booting: "Starting",
|
||||
running: "Running",
|
||||
suspended: "Sleeping",
|
||||
error: "Error",
|
||||
openComputer: "Start computer",
|
||||
stopTask: "Stop task",
|
||||
takeControl: "Take control",
|
||||
takeOverNow: "Take over",
|
||||
releaseControl: "Release control",
|
||||
done: "Done",
|
||||
skip: "Skip",
|
||||
localWorkspace: "Local workspace",
|
||||
pinned: "Pinned",
|
||||
agentGroup: "Agent",
|
||||
operationFailed: "That didn’t work",
|
||||
loadFailed: "Couldn’t load",
|
||||
settingsFailed: "Couldn’t save settings",
|
||||
loginFailed: "Couldn’t sign in",
|
||||
rememberFailed: "Couldn’t remember that message",
|
||||
clipboardWriteBlocked: "The browser blocked clipboard access.",
|
||||
newConversation: "New chat",
|
||||
workTools: "Work tools",
|
||||
computer: "Computer",
|
||||
memory: "Memory",
|
||||
plugins: "Plugins",
|
||||
pluginShort: "Plugin",
|
||||
botSettings: "Bot settings",
|
||||
add: "Add",
|
||||
addBot: "New bot",
|
||||
addGroup: "New group",
|
||||
groups: "Groups",
|
||||
unreadMessages: "{count} unread",
|
||||
members: "{count} members",
|
||||
hideHiddenItems: "Hide hidden items",
|
||||
showHiddenItems: "Show hidden items",
|
||||
mcpConnected: "{count} MCP connected",
|
||||
connectMcpServer: "Connect an MCP server",
|
||||
openOnPhone: "Open on phone",
|
||||
settings: "Settings",
|
||||
about: "About",
|
||||
helpCenter: "Help",
|
||||
sendFeedback: "Send feedback",
|
||||
logout: "Sign out",
|
||||
workspaceMenu: "Workspace menu",
|
||||
chooseBot: "Choose a bot",
|
||||
startRoomDiscussion: "Start a discussion with {name}",
|
||||
startBotWork: "Start working with {name}",
|
||||
roomWillReply: "{names} will all reply.",
|
||||
botWelcome: "Send a message and it will get it done on its own computer.",
|
||||
remembered: "Remembered",
|
||||
remember: "Remember",
|
||||
working: "{name} is working…",
|
||||
copyCode: "Copy code",
|
||||
copiedCode: "Copied",
|
||||
copyMessage: "Copy",
|
||||
copiedMessage: "Copied",
|
||||
anotherConversationQueued: "Another chat is running, so this one is queued.",
|
||||
workingStep: "{name} is working… {step}",
|
||||
queuedMessages: "{count} more messages queued",
|
||||
pausedUserControl: "You’re controlling the screen, so {name} is paused. Press “Release control” when you’re done and it will continue from here (queued messages too).",
|
||||
pausedNeedsUser: "{name} needs you on the screen (sign-in or verification). Press “Take over”, then “Release control” when you’re done.",
|
||||
releaseAndContinue: "Release and continue",
|
||||
attachFile: "Attach file",
|
||||
attachFileHint: "Images go to the model on this message. Other files land in the computer inbox and are deleted after two hours.",
|
||||
attachTooMany: "Up to 4 attachments at a time",
|
||||
attachTooLarge: "Each file must be 10 MB or smaller",
|
||||
attachType: "That file type can’t be attached",
|
||||
attachRemove: "Remove {name}",
|
||||
attachedFile: "Attached {name}",
|
||||
attachDrop: "Drop to attach",
|
||||
teachTask: "Teach a task",
|
||||
teachTaskHint: "You demonstrate once; it turns that into a skill",
|
||||
teachNeedsBot: "Choose a bot first",
|
||||
taughtSkills: "Taught skills",
|
||||
goalCommandHint: "Keep planning and executing until it’s verified or it needs you",
|
||||
searchSkills: "Search skills",
|
||||
noMatchingSkills: "No matching skills",
|
||||
teachDialogLead: "You’ll drive the screen while {name} watches: which controls you click, what you type, which pages you visit. It becomes a skill about intent and flow, not replayed coordinates.",
|
||||
teachGoalLabel: "What are you demonstrating? (one sentence)",
|
||||
teachGoalPlaceholder: "For example: open the STAR training system, finish the assigned videos, and complete the quiz",
|
||||
teachTip1: "Work at a normal pace. Extra clicks are ignored.",
|
||||
teachTip2: "Values that change (search terms, names, dates) can become parameters — use a real example while you demonstrate.",
|
||||
teachTip3: "Password fields are not recorded. Sign in before you start if you need to.",
|
||||
startDemo: "Start demo",
|
||||
finishDemo: "Finish demo",
|
||||
teachingBadge: "Recording",
|
||||
teachingComposerHint: "Recording: finish the demo before sending a message",
|
||||
teachingLive: "Learning: {goal}",
|
||||
teachingHint: "The screen is yours — work on the right (or enlarge it). Press “Finish demo” when you’re done.",
|
||||
teachingCaptured: "{count} actions captured",
|
||||
distilling: "Turning the “{goal}” demo into a skill…",
|
||||
skillDraftTitle: "New skill draft",
|
||||
skillDraftFrom: "From {count} actions",
|
||||
skillName: "Skill name",
|
||||
skillNamePlaceholder: "For example: Complete STAR training course",
|
||||
skillInputs: "Inputs",
|
||||
skillSteps: "Steps (intent, editable)",
|
||||
moreSteps: "…and {count} more",
|
||||
editSteps: "Edit steps",
|
||||
skillCheck: "How to know it’s done",
|
||||
skillDistillFailed: "The model couldn’t tidy this up. This list is from the raw actions — edit before saving.",
|
||||
discard: "Discard",
|
||||
testRun: "Try it",
|
||||
saveSkill: "Save skill",
|
||||
messageTo: "Message {name}",
|
||||
chooseConversationFirst: "Choose a chat first",
|
||||
stopConversation: "Stop",
|
||||
editSkill: "Edit skill",
|
||||
editSkillFull: "Full edit",
|
||||
runSkillNamed: "Run “{name}”",
|
||||
skillEditLead: "Write this for the bot: intent and on-screen wording, not coordinates. One item per line.",
|
||||
skillIntent: "Intent (what this skill should achieve)",
|
||||
skillWhenToUse: "When to use it",
|
||||
skillInputsLabel: "Inputs (one per line: name: description | e.g. example)",
|
||||
skillInputsPlaceholder: "courseName: course title | e.g. Workplace Bullying Prevention (2026)",
|
||||
skillPreconditions: "Preconditions",
|
||||
skillStepsLabel: "Steps (one per line; use → for what you should see)",
|
||||
skillStepsPlaceholder: "Click “Next” → page number increases; if Next is locked, wait until it enables",
|
||||
skillHowToCheck: "How to know it’s done",
|
||||
skillWhatToReturn: "What to report when finished",
|
||||
skillCautions: "Cautions (one per line)",
|
||||
deleteSkill: "Delete skill",
|
||||
deleteSkillConfirm: "Delete skill “{name}”? The bot won’t recognize it after that.",
|
||||
skillSaved: "Saved",
|
||||
exportSkill: "Export",
|
||||
exportSkillHint: "Download as JSON to import on another bot",
|
||||
importSkill: "Import skill",
|
||||
importSkillHint: "Add a skill from a previously exported JSON file",
|
||||
skillImportInvalid: "That isn’t a valid skill file. Choose a JSON export from LazyBoy.",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
botComputer: "{name}’s computer",
|
||||
dedicatedScreen: "This screen",
|
||||
enlarge: "Enlarge",
|
||||
userControlling: "You’re in control",
|
||||
aiReadOnly: "AI is working (view only)",
|
||||
readOnly: "View only",
|
||||
pasteClipboard: "Paste clipboard",
|
||||
copyDesktopClipboard: "Copy desktop clipboard",
|
||||
moreActions: "More",
|
||||
unpin: "Unpin",
|
||||
pin: "Pin",
|
||||
markUnread: "Mark unread",
|
||||
enterGroupName: "Enter a group name",
|
||||
changeGroup: "Change group",
|
||||
createOrMoveGroup: "Create / move to group",
|
||||
removeFromGroup: "Remove from group",
|
||||
unhide: "Unhide",
|
||||
hide: "Hide",
|
||||
delete: "Delete",
|
||||
chooseConversation: "Choose a chat",
|
||||
deleteConversation: "Delete chat",
|
||||
clearConversation: "Clear this chat",
|
||||
clearConversationTitle: "Clear this chat?",
|
||||
clearConversationDescription: "Messages will be deleted. This agent’s long-term memory stays.",
|
||||
cancel: "Cancel",
|
||||
clearMessages: "Clear messages",
|
||||
deleteConversationTitle: "Delete this chat?",
|
||||
deleteConversationDescription: "It will leave the list. This agent’s long-term memory stays.",
|
||||
avatarAppearance: "Avatar",
|
||||
avatarAppearanceHint: "Color, shape, expression, and background. Eyes follow the cursor.",
|
||||
color: "Color",
|
||||
chooseColor: "Choose {value}",
|
||||
customColor: "Custom color",
|
||||
shape: "Shape",
|
||||
expression: "Expression",
|
||||
background: "Background",
|
||||
bgNone: "None",
|
||||
bgCircle: "Circle",
|
||||
bgSquircle: "Squircle",
|
||||
bgSquare: "Square",
|
||||
exprIdle: "Idle",
|
||||
exprHappy: "Happy",
|
||||
exprSad: "Sad",
|
||||
exprMad: "Mad",
|
||||
exprSurprised: "Surprised",
|
||||
exprWink: "Wink",
|
||||
exprSleepy: "Sleepy",
|
||||
exprSmug: "Smug",
|
||||
exprUnsure: "Unsure",
|
||||
exprScared: "Scared",
|
||||
exprLove: "Love",
|
||||
exprShy: "Shy",
|
||||
exprSick: "Sick",
|
||||
exprThinking: "Thinking",
|
||||
name: "Name",
|
||||
tags: "Tags",
|
||||
tagsPlaceholder: "research, design, support (comma-separated)",
|
||||
tagsLimit: "Up to 6 tags",
|
||||
shortTitle: "Short title",
|
||||
shortTitlePlaceholder: "For example: product research assistant",
|
||||
description: "Description",
|
||||
descriptionPlaceholder: "What this bot is for",
|
||||
saving: "Saving…",
|
||||
saveSettings: "Save settings",
|
||||
deleteBot: "Delete bot",
|
||||
memoryHelp: "Clearing a chat doesn’t delete these. Store clear preferences or facts — never passwords.",
|
||||
enableLongTermMemory: "Long-term memory",
|
||||
addMemory: "Add memory",
|
||||
memoryPlaceholder: "Clear preferences or facts only. Passwords and tokens are rejected.",
|
||||
memorySearch: "Search",
|
||||
filterMemory: "Filter memory",
|
||||
noMemory: "No long-term memory yet. Ask the agent to remember something from chat, or add it here.",
|
||||
noMatchingMemory: "No matching memories.",
|
||||
save: "Save",
|
||||
edit: "Edit",
|
||||
clearAllMemoryConfirm: "Clear all memory?",
|
||||
clearMemoryConfirm: "Clear",
|
||||
clearAll: "Clear all",
|
||||
mcpHelp: "Press “Choose MCP” to connect from the catalog. If a key is required, fill it in first. Local stdio servers start inside the API container.",
|
||||
mcpNamePlaceholder: "For example: github",
|
||||
mcpConnectFailed: "Couldn’t connect. Check the key and try again.",
|
||||
command: "Command",
|
||||
commandPlaceholder: "npx, uvx, or a full path",
|
||||
arguments: "Arguments",
|
||||
environmentVariables: "Environment KEY=value",
|
||||
headers: "Headers KEY=value",
|
||||
connecting: "Connecting…",
|
||||
connectMcp: "Connect MCP",
|
||||
chooseMcp: "Choose MCP",
|
||||
mcpPickerTitle: "Choose MCP",
|
||||
mcpPickerLead: "Pick a featured or registry server. Every agent can use its tools.",
|
||||
searchMcp: "Search MCP",
|
||||
mcpRemote: "Remote",
|
||||
mcpLocal: "Local",
|
||||
mcpAdded: "Connected",
|
||||
mcpNeedsKey: "Needs a key",
|
||||
mcpNoResults: "No matching MCP. Try another search, or connect a custom server.",
|
||||
mcpCustom: "Custom",
|
||||
mcpBackToList: "Back to list",
|
||||
mcpConnectNamed: "Connect {name}",
|
||||
mcpKeyHint: "This MCP needs credentials.",
|
||||
noMcp: "No MCP yet. Press “Choose MCP” above to connect one from the catalog.",
|
||||
toolsCount: "{count} tools",
|
||||
disabled: "Off",
|
||||
disconnected: "Disconnected",
|
||||
noTools: "No tools",
|
||||
reconnect: "Reconnect",
|
||||
disable: "Disable",
|
||||
enable: "Enable",
|
||||
preparingDesktop: "Preparing this agent’s desktop…",
|
||||
computerPreviewHint: "Start the computer and the screen will show up here.",
|
||||
bootingProgress: "Starting…",
|
||||
restartComputer: "Restart",
|
||||
shutDownComputer: "Shut down",
|
||||
computerPower: "Computer menu",
|
||||
sharedPowerHint: "This is a shared computer. The action affects every agent using it.",
|
||||
stopAndTakeOver: "Stop and take over",
|
||||
hudBooting: "Computer starting…",
|
||||
hudWaking: "Waking…",
|
||||
hudConnecting: "Connecting…",
|
||||
hudHandoff: "Handing off…",
|
||||
pasteToRemoteComputer: "Paste to remote computer",
|
||||
pasteRemoteHelp: "Paste text here, then send it into VNC. This works over plain HTTP on the LAN.",
|
||||
pasteTextPlaceholder: "Paste text here…",
|
||||
pasteIntoVnc: "Paste into VNC",
|
||||
botNamePlaceholder: "For example: research assistant",
|
||||
sharedComputerHint: "Shares the environment with other bots",
|
||||
privateComputerHint: "A fresh dedicated Docker desktop",
|
||||
create: "Create",
|
||||
groupDescription: "A group opens one chat where every selected agent speaks.",
|
||||
groupName: "Group name",
|
||||
groupNamePlaceholder: "For example: product research",
|
||||
chooseBots: "Choose bots (at least two)",
|
||||
creating: "Creating…",
|
||||
createGroup: "Create group",
|
||||
deleteGroup: "Delete group",
|
||||
deleteNamed: "Delete {name}?",
|
||||
deleteGroupDescription: "The group chat will be deleted. The bots and their own chats and memory stay.",
|
||||
deleteDedicatedBotDescription: "The chat, private computer, and files on it will be permanently deleted.",
|
||||
deleteSharedBotDescription: "The chat will be deleted. The shared computer and its files stay.",
|
||||
phoneAccessDescription: "On the same LAN, open this URL in a phone browser and sign in with the same access token.",
|
||||
localAddressWarning: "This is a loopback address — a phone can’t open it. Use this computer’s LAN IP, for example http://192.168.x.x:3101.",
|
||||
close: "Close",
|
||||
copied: "Copied",
|
||||
copyUrl: "Copy URL",
|
||||
workspaceName: "Workspace name",
|
||||
showHiddenBots: "Show hidden bots",
|
||||
collapseRightSidebar: "Collapse right sidebar",
|
||||
workspaceSettingsHint: "Avatar, memory, and MCP live in the right-hand tabs. This only changes how this workspace is shown.",
|
||||
modelSettings: "Models",
|
||||
modelSettingsHint: "This provider and model apply to bots that don’t have their own.",
|
||||
modelProvider: "Model provider",
|
||||
providerXai: "xAI",
|
||||
providerOpencodeGo: "OpenCode Go",
|
||||
providerOpenaiCompatible: "OpenAI-compatible",
|
||||
providerXaiHint: "Grok. Get a key from console.x.ai.",
|
||||
providerOpencodeGoHint: "OpenCode Go subscription. Get a key from opencode.ai/auth.",
|
||||
providerOpenaiCompatibleHint: "Self-hosted vLLM, Ollama, LiteLLM, or another OpenAI-compatible endpoint.",
|
||||
apiKey: "API key",
|
||||
apiKeyPlaceholder: "sk-…",
|
||||
apiKeyStored: "A key is saved. Leave blank to keep it.",
|
||||
usingEnvKey: "Using environment variable {name}.",
|
||||
clearApiKey: "Clear saved key",
|
||||
modelId: "Model",
|
||||
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
|
||||
reloadModels: "Reload models",
|
||||
baseUrl: "Base URL",
|
||||
baseUrlPlaceholder: "http://127.0.0.1:11434/v1",
|
||||
baseUrlHint: "If the API runs in Docker, use host.docker.internal instead of localhost for services on this machine.",
|
||||
workspaceModelHint: "Pick a provider, then the key and model. OpenAI-compatible needs your own endpoint.",
|
||||
aboutDescription: "A local multi-agent workspace. Each bot has its own Linux computer. You can put them in a group chat and connect MCP tools.",
|
||||
apiStatus: "API status: {status}",
|
||||
statusNormal: "OK",
|
||||
statusUnavailable: "Unreachable",
|
||||
statusChecking: "Checking…",
|
||||
helpBotsTitle: "Bots",
|
||||
helpBots: "Use + at the top left to add a bot. Click the list to chat. Right-click to pin, hide, or delete.",
|
||||
helpGroupsTitle: "Groups",
|
||||
helpGroups: "+ → New group, pick at least two. One message, every agent in the group replies.",
|
||||
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.",
|
||||
helpMemoryTitle: "Memory",
|
||||
helpMemory: "Clearing a chat doesn’t wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.",
|
||||
helpMcpTitle: "MCP plugins",
|
||||
helpMcp: "Plugins in the lower left connect MCP servers. Their tools show up on screen and the agent can use them in chat.",
|
||||
helpSkillsTitle: "Skills",
|
||||
helpSkills: "+ → Teach a task, demonstrate once, and it becomes a skill. Export JSON after, or import someone else’s file onto another bot.",
|
||||
helpAttachTitle: "Attachments",
|
||||
helpAttach: "+ → Attach a file. Images go to the model on that message and aren’t stored in chat history. If the computer needs the original, it sits in inbox/ for two hours, then is deleted.",
|
||||
helpShortcutsTitle: "Shortcuts",
|
||||
helpShortcuts: "Enter sends, Shift+Enter makes a new line. While it’s replying, the send button becomes Stop. On a call, Space interrupts and Esc hangs up.",
|
||||
helpVoiceTitle: "Voice calls",
|
||||
helpVoice: "The phone in a 1:1 header calls the bot. Speak a task and it uses its computer. It answers immediately whether it can, and you can interrupt to change course.",
|
||||
call: "Call",
|
||||
hangUp: "Hang up",
|
||||
interrupt: "Interrupt",
|
||||
listening: "Listening…",
|
||||
speaking: "Speaking…",
|
||||
callConnecting: "Connecting…",
|
||||
workingOnComputer: "Working on the computer…",
|
||||
callPrompt: "Just say what you need. A short pause sends it.",
|
||||
callShortcuts: "Space interrupts · Esc hangs up",
|
||||
callFailed: "The call dropped.",
|
||||
micDenied: "Microphone permission is required for calls.",
|
||||
micFailed: "Couldn’t open the microphone.",
|
||||
callAudioFailed: "Call audio stopped working. Hang up and call again.",
|
||||
takeOverToContinue: "You need to take over the screen.",
|
||||
setUpVoiceToCall: "Set up voice before calling",
|
||||
voiceEnabled: "Enable voice calls",
|
||||
voiceEnabledHint: "Off by default. Turn on and save to use the call button.",
|
||||
voiceDisabledHint: "Voice calls are off. Turn them on in Settings → Voice.",
|
||||
voiceSettings: "Voice",
|
||||
voiceSettingsHint: "Voice is separate from the text model. If you already have an xAI chat key, you usually don’t need another. Billed per minute.",
|
||||
voiceProvider: "Voice provider",
|
||||
voiceModel: "Voice model",
|
||||
voiceName: "Voice",
|
||||
providerOpenai: "OpenAI",
|
||||
providerXaiVoiceHint: "Grok Voice. Live calls with barge-in; computer work is handed to the text model.",
|
||||
providerOpenaiVoiceHint: "OpenAI Realtime. Same call UI; you can switch later.",
|
||||
voiceReusesTextKey: "Reusing the xAI key saved for the text model.",
|
||||
feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.",
|
||||
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
|
||||
copyContent: "Copy",
|
||||
loginTitle: "Sign in to LazyBoy",
|
||||
loginDescription: "Enter the shared access token configured on the server.",
|
||||
accessToken: "Access token",
|
||||
verifying: "Checking…",
|
||||
login: "Sign in",
|
||||
agentComputer: "Agent computer",
|
||||
url: "URL",
|
||||
stdio: "stdio",
|
||||
http: "HTTP",
|
||||
sse: "SSE",
|
||||
argumentsPlaceholder: "-y @modelcontextprotocol/server-github",
|
||||
environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
|
||||
urlPlaceholder: "https://mcp.example.com/mcp",
|
||||
headersPlaceholder: "Authorization=Bearer …",
|
||||
back: "Back",
|
||||
accounts: "Accounts",
|
||||
accountsHelp: "Stored in this bot’s vault. Passwords are entered only here — never in chat, never shown to the model. Schedules fill the form at a login wall.",
|
||||
accountSite: "Site",
|
||||
accountSitePlaceholder: "For example: Gmail",
|
||||
accountHost: "URL or domain",
|
||||
accountHostPlaceholder: "mail.google.com",
|
||||
accountUsername: "Username",
|
||||
accountPassword: "Password",
|
||||
accountPasswordKeep: "Leave blank to keep the current password",
|
||||
accountNotes: "Notes",
|
||||
accountNotesPlaceholder: "Optional, for example: work inbox",
|
||||
addAccount: "Add account",
|
||||
noAccounts: "No saved accounts yet. Add one here if a schedule should sign in by itself.",
|
||||
loginNeedsYou: "Needs you to sign in on the keyboard",
|
||||
loginOpenScreen: "Open its screen",
|
||||
loginWhy: "After sign-in: {why}",
|
||||
scheduleChip: "Scheduled",
|
||||
scheduleRunChip: "Schedule run",
|
||||
schedules: "Schedules",
|
||||
schedule: "Schedule",
|
||||
schedCreate: "New schedule",
|
||||
schedEmpty: "No schedules yet. Say “every day at 9…” in chat, or press +.",
|
||||
schedPaused: "Paused",
|
||||
schedRunNow: "Run now",
|
||||
schedRunning: "Running…",
|
||||
schedActive: "On",
|
||||
schedNamePlaceholder: "For example: morning digest",
|
||||
schedInstruction: "What to do each time",
|
||||
schedInstructionPlaceholder: "For example: open mail and write an unread summary back to chat.",
|
||||
schedWhen: "When",
|
||||
schedEveryHour: "Every hour",
|
||||
schedEveryDay: "Every day",
|
||||
schedWeekdays: "Weekdays",
|
||||
schedEveryWeek: "Every Monday",
|
||||
schedEveryMonth: "Monthly",
|
||||
schedInterval: "Interval",
|
||||
schedAdvanced: "Advanced cron",
|
||||
schedMinutes: "minutes",
|
||||
schedHours: "hours",
|
||||
schedDays: "days",
|
||||
computerLoginHint: "Sign in inside this bot’s own browser. The session stays on its computer — don’t type passwords in chat.",
|
||||
language: "Interface language",
|
||||
languageZh: "繁體中文",
|
||||
languageEn: "English",
|
||||
languageHint: "Interface language. Chat history and agent replies are not translated.",
|
||||
helpLanguageTitle: "Language",
|
||||
helpLanguage: "Switch between Traditional Chinese and English in Settings, accessible from the workspace menu.",
|
||||
clipboardSynced: "Clipboard synced",
|
||||
clipboardSyncBlocked: "The browser blocked sync. Use the copy button.",
|
||||
clipboardPasted: "Pasted",
|
||||
clipboardPasteFailed: "Paste failed. Take over the computer and try again.",
|
||||
clipboardCopiedSelection: "Copied selection",
|
||||
clipboardCopyFailed: "Copy didn’t sync. Use the copy button, or Ctrl+Shift+C on the desktop.",
|
||||
slashMode: "Mode",
|
||||
slashFileSkill: "Skill",
|
||||
slashList: "Commands and skills",
|
||||
schedIntervalHint: "Elapsed time, not the clock. A day is 24 hours and doesn’t reset at month-end or DST.",
|
||||
schedInvalidInterval: "Interval must be a whole number from 1 to 365",
|
||||
schedHumanDaily: "Daily at {time}",
|
||||
schedHumanWeekdays: "Weekdays at {time}",
|
||||
schedHumanWeekly: "Mondays at {time}",
|
||||
schedHumanMonthly: "Monthly at {time}",
|
||||
schedHumanInterval: "Every {n} {unit}",
|
||||
stepThinking: "Thinking",
|
||||
stepWatchingScreen: "Looking at the screen",
|
||||
stepUsingSkill: "Using skill {name}",
|
||||
stepFillingLogin: "Filling a saved login",
|
||||
stepListingAccounts: "Listing saved accounts",
|
||||
stepScheduling: "Scheduling {name}",
|
||||
stepListingSchedules: "Listing schedules",
|
||||
stepCancelingSchedule: "Canceling a schedule",
|
||||
stepRecheckingScreen: "Checking the screen again",
|
||||
schedEveryMinute: "Every minute",
|
||||
schedHumanEveryMinutes: "Every {n} minutes",
|
||||
schedHumanEveryHours: "Every {n} hours",
|
||||
schedHumanMonthlyDay: "Monthly on the 1st at {time}",
|
||||
schedHumanElapsed: "Every {n} minutes (elapsed)",
|
||||
schedCalendar: "Calendar schedule: {expr}",
|
||||
teachInProgress: "A demo is in progress. Finish or cancel it before sending a message.",
|
||||
aiTimeout: "The model timed out (120 seconds).",
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ export const zhTW = {
|
|||
attachTooMany: "一次最多 4 個附件", attachTooLarge: "單檔不能超過 10 MB", attachType: "這個檔案類型不能附加",
|
||||
attachRemove: "移除 {name}", attachedFile: "附件 {name}", attachDrop: "放到這裡附加",
|
||||
teachTask: "教它一項任務", teachTaskHint: "你示範一次,它學成技能", teachNeedsBot: "先選擇一個機器人", taughtSkills: "已學會的技能",
|
||||
goalCommandHint: "持續規劃並執行,直到驗證完成或需要你介入",
|
||||
searchSkills: "搜尋技能", noMatchingSkills: "沒有符合的技能",
|
||||
teachDialogLead: "接下來畫面交給你操作,{name} 會在旁邊看:記錄你點了哪些控制項、輸入了什麼、去了哪些頁面,之後整理成一個「知道目的與流程」的技能,而不是死記座標。",
|
||||
teachGoalLabel: "你要示範什麼?(一句話說目標)", teachGoalPlaceholder: "例如:到 STAR 訓練系統,把指定課程的影片看完並完成測驗",
|
||||
|
|
@ -63,7 +64,7 @@ export const zhTW = {
|
|||
mcpNoResults: "找不到符合的 MCP。換個關鍵字,或改用自訂接入。", mcpCustom: "自訂接入", mcpBackToList: "回到列表",
|
||||
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
|
||||
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
|
||||
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管",
|
||||
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartComputer: "重啟", shutDownComputer: "關閉電腦", computerPower: "電腦選單", sharedPowerHint: "此為共用電腦,操作會影響使用它的所有 Agent。", stopAndTakeOver: "停止並接管",
|
||||
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
|
||||
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
|
||||
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
|
||||
|
|
@ -92,7 +93,19 @@ export const zhTW = {
|
|||
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
|
||||
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
|
||||
helpAttachTitle: "附件", helpAttach: "+ → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
|
||||
helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出,Shift+Enter 換行。正在回覆時送出鈕會變成停止。",
|
||||
helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出,Shift+Enter 換行。正在回覆時送出鈕會變成停止。通話中空白鍵插話,Esc 掛斷。",
|
||||
helpVoiceTitle: "語音通話", helpVoice: "1:1 對話標題列的電話可以打給機器人。用說話下指令,它會去操作自己的電腦;能不能做會立刻用聲音回你,中途可以插嘴改指令。",
|
||||
call: "通話", hangUp: "掛斷", interrupt: "插話", listening: "正在聽你說…", speaking: "正在說話…",
|
||||
callConnecting: "連線中…", workingOnComputer: "正在電腦上做…", callPrompt: "直接說要做什麼。沉默一下就會送出。",
|
||||
callShortcuts: "空白鍵插話 · Esc 掛斷", callFailed: "通話中斷了。", micDenied: "需要麥克風權限才能通話。", micFailed: "麥克風打不開。",
|
||||
callAudioFailed: "通話聲音出問題了,請掛斷後重打。",
|
||||
takeOverToContinue: "需要你接手畫面。", setUpVoiceToCall: "先設定語音才能通話",
|
||||
voiceEnabled: "啟用語音通話", voiceEnabledHint: "預設關閉。開啟並儲存後,才能使用通話按鈕。", voiceDisabledHint: "語音通話尚未啟用,請到「設定 → 語音」開啟。",
|
||||
voiceSettings: "語音", voiceSettingsHint: "語音跟文字模型分開。xAI 若已有聊天金鑰,通常不用再填一次。按分鐘計費。",
|
||||
voiceProvider: "語音供應商", voiceModel: "語音模型", voiceName: "聲音",
|
||||
providerOpenai: "OpenAI", providerXaiVoiceHint: "Grok Voice。即時通話,可插嘴,並把電腦工作交給文字模型去做。",
|
||||
providerOpenaiVoiceHint: "OpenAI Realtime。同一套通話介面,之後可切換。",
|
||||
voiceReusesTextKey: "目前會重用文字模型已存的 xAI 金鑰。",
|
||||
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
|
||||
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
|
||||
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
|
||||
|
|
@ -113,4 +126,27 @@ export const zhTW = {
|
|||
schedEveryWeek: "每週一", schedEveryMonth: "每月", schedInterval: "間隔", schedAdvanced: "進階 cron",
|
||||
schedMinutes: "分鐘", schedHours: "小時", schedDays: "天",
|
||||
computerLoginHint: "在這台機器人自己的瀏覽器登入。Session 留在它的電腦,不要把密碼打在聊天裡。",
|
||||
language: "介面語言", languageZh: "繁體中文", languageEn: "English",
|
||||
languageHint: "介面語言。對話內容與 Agent 回覆不會自動翻譯。",
|
||||
helpLanguageTitle: "語言", helpLanguage: "在左下角工作區選單的設定裡,可切換繁體中文與英文。",
|
||||
clipboardSynced: "剪貼簿已同步", clipboardSyncBlocked: "瀏覽器未允許同步,請按複製按鈕",
|
||||
clipboardPasted: "已貼上文字", clipboardPasteFailed: "貼上失敗,請確認已接管電腦後重試",
|
||||
clipboardCopiedSelection: "已複製選取文字", clipboardCopyFailed: "複製未同步;請按複製按鈕,或在遠端使用 Ctrl+Shift+C",
|
||||
slashMode: "執行模式", slashFileSkill: "檔案技能", slashList: "指令與技能",
|
||||
schedIntervalHint: "固定經過時間;一天為 24 小時,不因月底或日光節約時間重置。",
|
||||
schedInvalidInterval: "間隔必須為 1–365 的整數",
|
||||
schedHumanDaily: "每天 {time}", schedHumanWeekdays: "工作日 {time}",
|
||||
schedHumanWeekly: "每週一 {time}", schedHumanMonthly: "每月 {time}",
|
||||
schedHumanInterval: "每 {n} {unit}",
|
||||
stepThinking: "思考中", stepWatchingScreen: "看畫面", stepUsingSkill: "讀取技能 {name}",
|
||||
stepFillingLogin: "填入已存帳號", stepListingAccounts: "列出已存帳號",
|
||||
stepScheduling: "排程 {name}", stepListingSchedules: "列出排程", stepCancelingSchedule: "取消排程",
|
||||
stepRecheckingScreen: "重新確認畫面",
|
||||
schedEveryMinute: "每分鐘",
|
||||
schedHumanEveryMinutes: "每 {n} 分鐘", schedHumanEveryHours: "每 {n} 小時",
|
||||
schedHumanMonthlyDay: "每月 1 日 {time}",
|
||||
schedHumanElapsed: "每隔 {n} 分鐘(固定間隔)",
|
||||
schedCalendar: "日曆排程:{expr}",
|
||||
teachInProgress: "示範進行中:先按「完成示範」或「取消」,再送訊息。",
|
||||
aiTimeout: "AI 回應逾時(120 秒)",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import "./avatar.css";
|
|||
import "./chat.css";
|
||||
import "./computer.css";
|
||||
import "./schedule.css";
|
||||
import "./call.css";
|
||||
import "./responsive.css";
|
||||
import { App } from "./App";
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
import { LocaleRoot } from "./i18n";
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<LocaleRoot><App /></LocaleRoot>);
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@
|
|||
}
|
||||
.md-body a:hover{color:#7ee0c8}
|
||||
.md-body code{
|
||||
border:1px solid var(--border);border-radius:.35em;background:#101012;
|
||||
border:1px solid var(--border);border-radius:.35em;background:var(--inset);
|
||||
padding:.08em .32em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
font-size:.88em;direction:ltr;unicode-bidi:isolate
|
||||
}
|
||||
.md-body pre{
|
||||
max-width:100%;overflow-x:auto;margin:0;border:1px solid var(--border);
|
||||
border-radius:12px;background:#0c0c0d;padding:.75em .9em;direction:ltr;unicode-bidi:isolate
|
||||
border-radius:12px;background:var(--input);padding:.75em .9em;direction:ltr;unicode-bidi:isolate
|
||||
}
|
||||
.md-body pre code{border:0;background:transparent;padding:0;font-size:.84em;white-space:pre}
|
||||
.md-pre-wrap{position:relative;margin:.65em 0}
|
||||
|
|
@ -51,6 +51,6 @@
|
|||
.md-body img{display:block;max-width:100%;height:auto;margin:.65em 0;border-radius:12px}
|
||||
.md-body input[type="checkbox"]{margin-right:.4em;accent-color:var(--accent)}
|
||||
.message-body.md{white-space:normal}
|
||||
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:#1a1a1a}
|
||||
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:var(--on-cream)}
|
||||
.user .md-body a{color:#0f766e}
|
||||
.user .md-copy{background:#ececea;color:#555}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
|
||||
.memory-list{display:grid;gap:8px;margin:14px 0}
|
||||
|
||||
.memory-row{display:grid;gap:5px;padding:10px;border:1px solid var(--border,#ddd);border-radius:10px}
|
||||
.memory-row{display:grid;gap:5px;padding:10px;border:1px solid var(--border);border-radius:10px}
|
||||
|
||||
.memory-row small{opacity:.65}
|
||||
|
||||
.memory-row>div{display:flex;gap:8px}
|
||||
|
||||
.memory-row textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit}
|
||||
.memory-row textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:11px;font:inherit}
|
||||
|
||||
|
||||
.app-shell{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,28vw,420px)}
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
.primary{display:inline-flex;align-items:center;justify-content:center}
|
||||
|
||||
|
||||
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:#0c0c0d;color:var(--text);padding:12px;font:inherit;line-height:1.5;outline:none}
|
||||
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:var(--input);color:var(--ink);padding:12px;font:inherit;line-height:1.5;outline:none}
|
||||
|
||||
.clipboard-text:focus{border-color:var(--accent)}
|
||||
|
||||
|
|
@ -66,9 +66,9 @@
|
|||
|
||||
.bot-copy strong,.bot-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--text);background:rgba(255,255,255,.055)}
|
||||
.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--ink);background:rgba(255,255,255,.055)}
|
||||
|
||||
.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--sidebar,#0b0b0c);border-radius:50%;background:#35d07f;box-shadow:0 0 8px rgba(53,208,127,.75)}
|
||||
.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:var(--unread);box-shadow:0 0 8px color-mix(in srgb,var(--unread) 65%,transparent)}
|
||||
|
||||
.bot-group{display:grid;gap:3px}
|
||||
|
||||
|
|
@ -83,9 +83,9 @@
|
|||
.hidden-toggle svg{width:14px}
|
||||
|
||||
|
||||
.context-menu{position:fixed;z-index:100;display:grid;width:210px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
||||
.context-menu{position:fixed;z-index:100;display:grid;width:210px;padding:6px;border:1px solid var(--border);border-radius:13px;background:var(--menu);box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
||||
|
||||
.context-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;text-align:left;cursor:pointer}
|
||||
.context-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
|
||||
|
||||
.context-menu button:hover{background:rgba(255,255,255,.07)}
|
||||
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
|
||||
.context-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}
|
||||
|
||||
.context-menu .danger-item{color:#ff7777}
|
||||
.context-menu .danger-item{color:var(--danger-soft)}
|
||||
|
||||
.context-menu .context-close{display:none}
|
||||
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
|
||||
.color-grid button,.custom-color{position:relative;box-sizing:border-box;width:34px;height:34px;border:2px solid transparent;border-radius:50%;cursor:pointer}
|
||||
|
||||
.color-grid button.selected{outline:2px solid var(--text);outline-offset:2px}
|
||||
.color-grid button.selected{outline:2px solid var(--ink);outline-offset:2px}
|
||||
|
||||
.custom-color{display:grid;place-items:center;overflow:hidden;border:1px dashed var(--muted)}
|
||||
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
|
||||
.shape-grid button.selected{border-color:var(--accent);background:rgba(62,197,168,.08)}
|
||||
|
||||
.settings-dialog textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--text);padding:11px;font:inherit;outline:none}
|
||||
.settings-dialog textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:11px;font:inherit;outline:none}
|
||||
|
||||
.settings-dialog textarea:focus{border-color:var(--accent)}
|
||||
|
||||
|
|
@ -178,7 +178,7 @@
|
|||
.workspace-avatar{display:grid;width:32px;height:32px;flex:0 0 32px;place-items:center;border-radius:50%;background:var(--surface);color:var(--muted);font-size:11px;font-weight:650;letter-spacing:.02em;box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)}
|
||||
|
||||
|
||||
.account:hover,.account.open{background:#171719}
|
||||
.account:hover,.account.open{background:var(--hairline)}
|
||||
|
||||
|
||||
.account .chevron{width:16px;height:16px;color:var(--muted);margin-left:auto;flex:0 0 16px;transition:transform .15s ease}
|
||||
|
|
@ -187,7 +187,7 @@
|
|||
.account.open .chevron{transform:rotate(180deg)}
|
||||
|
||||
|
||||
.account-menu{position:absolute;z-index:50;left:0;right:0;bottom:calc(100% + 8px);display:grid;padding:6px;border:1px solid var(--border);border-radius:14px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.55)}
|
||||
.account-menu{position:absolute;z-index:50;left:0;right:0;bottom:calc(100% + 8px);display:grid;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 18px 50px rgba(0,0,0,.55)}
|
||||
|
||||
|
||||
.account-menu button{display:flex;align-items:center;gap:10px;height:40px;padding:0 12px;border:0;border-radius:9px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
.account-menu hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--border)}
|
||||
|
||||
|
||||
.share-url{display:block;padding:10px 12px;border-radius:10px;background:#0c0c0d;color:var(--ink);font-size:13px;word-break:break-all}
|
||||
.share-url{display:block;padding:10px 12px;border-radius:10px;background:var(--input);color:var(--ink);font-size:13px;word-break:break-all}
|
||||
|
||||
|
||||
.about-brand{display:grid;justify-items:center;gap:6px;padding:8px 0 4px}
|
||||
|
|
@ -235,7 +235,7 @@
|
|||
.plugin-row,.plugin-server{display:flex;align-items:center;gap:10px;width:100%;padding:9px 10px;border:0;border-radius:12px;background:transparent;color:inherit;text-align:left;cursor:pointer}
|
||||
|
||||
|
||||
.plugin-row:hover,.plugin-server:hover,.plugin-row.selected{background:#171719}
|
||||
.plugin-row:hover,.plugin-server:hover,.plugin-row.selected{background:var(--hairline)}
|
||||
|
||||
|
||||
.plugin-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:rgba(255,255,255,.06);color:var(--muted);flex:0 0 28px}
|
||||
|
|
@ -253,13 +253,13 @@
|
|||
.mcp-dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:0 0 8px}
|
||||
|
||||
|
||||
.mcp-dot.connected{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.6)}
|
||||
.mcp-dot.connected{background:var(--online);box-shadow:0 0 8px color-mix(in srgb,var(--online) 60%,transparent)}
|
||||
|
||||
|
||||
.mcp-dot.disconnected{background:#ef5555}
|
||||
.mcp-dot.disconnected{background:var(--danger)}
|
||||
|
||||
|
||||
.mcp-dot.disabled{background:#626267}
|
||||
.mcp-dot.disabled{background:var(--faint)}
|
||||
|
||||
|
||||
.mcp-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px}
|
||||
|
|
@ -292,7 +292,7 @@
|
|||
.mcp-card-head small{color:var(--muted)}
|
||||
|
||||
|
||||
.mcp-error{padding:0 12px 10px;color:#ff8585;font-size:12px;line-height:1.4}
|
||||
.mcp-error{padding:0 12px 10px;color:var(--danger-soft);font-size:12px;line-height:1.4}
|
||||
|
||||
|
||||
.mcp-tools{display:grid;gap:6px;padding:0 12px 10px}
|
||||
|
|
@ -321,7 +321,7 @@
|
|||
.mcp-picker .dialog-title,.mcp-picker .dialog-lead,.mcp-picker .dialog-actions,.mcp-picker .pane-error{flex:0 0 auto}
|
||||
|
||||
|
||||
.dialog label.mcp-picker-search,.mcp-picker-search{display:flex;align-items:center;gap:10px;height:44px;margin:0;padding:0 13px;border:1px solid var(--line);border-radius:14px;background:#121214;color:var(--muted);font-size:inherit;flex:0 0 44px}
|
||||
.dialog label.mcp-picker-search,.mcp-picker-search{display:flex;align-items:center;gap:10px;height:44px;margin:0;padding:0 13px;border:1px solid var(--line);border-radius:14px;background:var(--elevated);color:var(--muted);font-size:inherit;flex:0 0 44px}
|
||||
|
||||
|
||||
.mcp-picker-search>div{flex:0 0 auto;display:grid;place-items:center;line-height:0}
|
||||
|
|
@ -379,9 +379,9 @@
|
|||
|
||||
.brand .create-menu-wrap .icon-button{margin-left:0}
|
||||
|
||||
.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.5)}
|
||||
.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:var(--menu);box-shadow:0 18px 50px rgba(0,0,0,.5)}
|
||||
|
||||
.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);cursor:pointer}
|
||||
.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);cursor:pointer}
|
||||
|
||||
.create-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}
|
||||
|
||||
|
|
@ -413,7 +413,7 @@
|
|||
.session-current svg{flex:0 0 14px;width:14px;height:14px;color:var(--muted)}
|
||||
|
||||
|
||||
.session-menu{position:absolute;z-index:40;top:42px;left:0;display:grid;width:min(280px,calc(100vw - 24px));padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
||||
.session-menu{position:absolute;z-index:40;top:42px;left:0;display:grid;width:min(280px,calc(100vw - 24px));padding:6px;border:1px solid var(--border);border-radius:13px;background:var(--menu);box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
||||
|
||||
|
||||
.session-menu>button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
.session-item .icon-button{flex:0 0 32px;width:32px;height:32px;color:var(--muted)}
|
||||
|
||||
|
||||
.session-menu .danger-item{color:#ff7777}
|
||||
.session-menu .danger-item{color:var(--danger-soft)}
|
||||
|
||||
|
||||
.queue-hint{position:absolute;bottom:118px;left:50%;z-index:7;transform:translateX(-50%);color:var(--muted);font-size:12px}
|
||||
|
|
@ -461,7 +461,7 @@
|
|||
.error-banner{z-index:8;bottom:118px}
|
||||
|
||||
|
||||
.pause-banner{display:flex;align-items:center;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid #5a4a1f;border-radius:12px;background:#221c0e;color:#fcd68a;font-size:13px;line-height:1.5}
|
||||
.pause-banner{display:flex;align-items:center;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid var(--warn-border);border-radius:12px;background:var(--warn-bg);color:var(--warn-fg);font-size:13px;line-height:1.5}
|
||||
|
||||
|
||||
.pause-banner span{flex:1}
|
||||
|
|
@ -518,7 +518,7 @@
|
|||
.side-rail button:disabled{opacity:.35;cursor:not-allowed}
|
||||
|
||||
|
||||
.side-card-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid #171719}
|
||||
.side-card-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid var(--hairline)}
|
||||
|
||||
|
||||
.side-card-title{min-width:0;flex:1;overflow:hidden;color:var(--muted);font-size:13px;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
|
@ -551,16 +551,16 @@
|
|||
.pane-form label,.memory-pane label,.mcp-add label,.vault-pane label{display:grid;gap:8px;color:var(--muted);font-size:13px;min-width:0}
|
||||
|
||||
|
||||
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input,.vault-pane input{width:100%;min-width:0;height:40px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
|
||||
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input,.vault-pane input{width:100%;min-width:0;height:40px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:0 12px;outline:0}
|
||||
|
||||
|
||||
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit;outline:none}
|
||||
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:11px;font:inherit;outline:none}
|
||||
|
||||
|
||||
.pane-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
|
||||
|
||||
.pane-error{color:#ff8585;font-size:13px}
|
||||
.pane-error{color:var(--danger-soft);font-size:13px}
|
||||
|
||||
|
||||
.settings-pane fieldset{margin:0;padding:0;border:0}
|
||||
|
|
@ -587,7 +587,7 @@
|
|||
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
|
||||
|
||||
|
||||
.login-screen{display:grid;min-height:100%;place-items:center;padding:20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
|
||||
.login-screen{position:relative;display:grid;min-height:100%;place-items:center;padding:88px 20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
|
||||
|
||||
.login-dialog{display:grid;justify-items:stretch}
|
||||
|
||||
|
|
@ -597,13 +597,19 @@
|
|||
|
||||
.login-dialog p{margin-top:-8px;color:var(--muted)}
|
||||
|
||||
.login-error{color:#ff8585;font-size:13px}
|
||||
.login-error{color:var(--danger-soft);font-size:13px}
|
||||
|
||||
.login-lang{position:absolute;top:24px;right:24px;display:flex;align-items:center;gap:6px}
|
||||
|
||||
.login-lang button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
|
||||
|
||||
.login-lang button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
|
||||
|
||||
|
||||
@keyframes working-shimmer{to{background-position:-220% 0}}
|
||||
|
||||
|
||||
.bot-row .unread-dot{top:-2px;right:-3px;background:#3b82f6;box-shadow:0 0 8px rgba(59,130,246,.65)}
|
||||
.bot-row .unread-dot{top:-2px;right:-3px}
|
||||
|
||||
|
||||
.bot-row:hover .avatar-wrap{transform:scale(1.06)}
|
||||
|
|
@ -671,14 +677,14 @@
|
|||
.dialog.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}
|
||||
|
||||
|
||||
.dialog.settings-dialog select{width:100%;height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink)}
|
||||
.dialog.settings-dialog select{width:100%;height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink)}
|
||||
|
||||
|
||||
/* Teach-by-demonstration: composer menu, live banner, draft card */
|
||||
.plus-menu-wrap{position:relative;align-self:center}
|
||||
|
||||
|
||||
.plus-menu{position:absolute;z-index:40;bottom:50px;left:0;display:grid;width:268px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
||||
.plus-menu{position:absolute;z-index:40;bottom:50px;left:0;display:grid;width:268px;padding:6px;border:1px solid var(--border);border-radius:13px;background:var(--menu);box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
||||
|
||||
|
||||
.plus-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
||||
|
|
@ -702,10 +708,10 @@
|
|||
.plus-menu-skills{display:grid;min-height:0}
|
||||
|
||||
|
||||
.plus-menu-search{box-sizing:border-box;width:calc(100% - 8px);height:32px;margin:4px 4px 6px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:#121214;color:var(--ink);font:inherit;font-size:13px;outline:0}
|
||||
.plus-menu-search{box-sizing:border-box;width:calc(100% - 8px);height:32px;margin:4px 4px 6px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--elevated);color:var(--ink);font:inherit;font-size:13px;outline:0}
|
||||
|
||||
|
||||
.plus-menu-search:focus{border-color:#4b4b50}
|
||||
.plus-menu-search:focus{border-color:var(--focus)}
|
||||
|
||||
|
||||
.plus-menu-skill-list{overflow-y:auto;max-height:min(228px,calc(100dvh - 280px));padding-bottom:2px}
|
||||
|
|
@ -720,7 +726,7 @@
|
|||
.plus-menu-empty{display:block;padding:10px 12px 12px;color:var(--muted);font-size:12px}
|
||||
|
||||
|
||||
.record-dot{display:inline-block;flex:0 0 12px;width:12px;height:12px;border-radius:50%;background:#ef5555;box-shadow:inset 0 0 0 2px #18181b,0 0 0 1.5px #ef5555}
|
||||
.record-dot{display:inline-block;flex:0 0 12px;width:12px;height:12px;border-radius:50%;background:var(--danger);box-shadow:inset 0 0 0 2px var(--menu),0 0 0 1.5px var(--danger)}
|
||||
|
||||
|
||||
.record-dot.live{animation:pulse 1.2s infinite;margin-right:8px;vertical-align:-1px}
|
||||
|
|
@ -729,10 +735,10 @@
|
|||
.teach-dialog h2{display:flex;align-items:center;gap:10px}
|
||||
|
||||
|
||||
.teach-goal{min-height:78px;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:10px 12px;outline:0;line-height:1.5}
|
||||
.teach-goal{min-height:78px;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:10px 12px;outline:0;line-height:1.5}
|
||||
|
||||
|
||||
.teach-goal:focus{border-color:#4b4b50}
|
||||
.teach-goal:focus{border-color:var(--focus)}
|
||||
|
||||
|
||||
.teach-tips{margin:0;padding-left:18px;color:var(--muted);font-size:12.5px;line-height:1.6}
|
||||
|
|
@ -768,10 +774,10 @@
|
|||
.skill-draft label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
|
||||
|
||||
|
||||
.skill-draft input,.skill-draft textarea{border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;line-height:1.5}
|
||||
.skill-draft input,.skill-draft textarea{border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:9px 12px;outline:0;font:inherit;line-height:1.5}
|
||||
|
||||
|
||||
.skill-draft input:focus,.skill-draft textarea:focus{border-color:#4b4b50}
|
||||
.skill-draft input:focus,.skill-draft textarea:focus{border-color:var(--focus)}
|
||||
|
||||
|
||||
.skill-draft textarea{resize:vertical}
|
||||
|
|
@ -798,7 +804,7 @@
|
|||
.skill-draft .link{width:fit-content;padding:0;border:0;background:none;color:var(--accent);font-size:12px;cursor:pointer}
|
||||
|
||||
|
||||
.skill-error{color:#fca5a5;font-size:12.5px}
|
||||
.skill-error{color:var(--error-fg);font-size:12.5px}
|
||||
|
||||
|
||||
.skill-actions{display:flex;align-items:center;gap:8px}
|
||||
|
|
@ -845,10 +851,10 @@
|
|||
.dialog.skill-edit label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
|
||||
|
||||
|
||||
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;font-size:13.5px;line-height:1.5;resize:vertical}
|
||||
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:9px 12px;outline:0;font:inherit;font-size:13.5px;line-height:1.5;resize:vertical}
|
||||
|
||||
|
||||
.dialog.skill-edit input:focus,.dialog.skill-edit textarea:focus{border-color:#4b4b50}
|
||||
.dialog.skill-edit input:focus,.dialog.skill-edit textarea:focus{border-color:var(--focus)}
|
||||
|
||||
|
||||
.dialog.skill-edit .dialog-actions{align-items:center;gap:8px;flex-wrap:wrap}
|
||||
|
|
@ -876,7 +882,7 @@
|
|||
|
||||
.vault-empty{margin:0;color:var(--faint);font-size:13px;line-height:1.5}
|
||||
|
||||
.account-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0;padding:10px 6px 10px 12px;border:1px solid var(--border);border-radius:12px;background:#101012}
|
||||
.account-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0;padding:10px 6px 10px 12px;border:1px solid var(--border);border-radius:12px;background:var(--inset)}
|
||||
|
||||
.account-row-main{display:grid;gap:3px;min-width:0}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.login-chip,.sched-chip{display:grid;gap:6px;margin:8px 0;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:#101113;max-width:min(var(--chat-col),100%)}
|
||||
.login-chip,.sched-chip{display:grid;gap:6px;margin:8px 0;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--inset);max-width:min(var(--chat-col),100%)}
|
||||
.login-chip .login-label,.sched-chip .sched-label{color:var(--muted);font-size:12px}
|
||||
.login-chip .login-site{font-weight:600}
|
||||
.login-chip .login-why{color:var(--muted);font-size:13px}
|
||||
|
|
@ -12,12 +12,12 @@
|
|||
.sched-row-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.sched-row-main small{color:var(--muted);font-size:12px}
|
||||
.sched-dot{width:8px;height:8px;border-radius:50%;background:var(--faint)}
|
||||
.sched-dot.on{background:#4ade80}
|
||||
.sched-dot.on{background:var(--online)}
|
||||
.sched-editor{display:grid;gap:12px;min-height:0;overflow:auto}
|
||||
.sched-editor-head{display:flex;align-items:center;gap:8px}
|
||||
.sched-when{display:grid;gap:8px;color:var(--muted);font-size:13px}
|
||||
.sched-card{display:flex;flex-wrap:wrap;gap:8px;padding:10px;border:1px solid var(--border);border-radius:12px;background:#0c0c0d}
|
||||
.sched-card select,.sched-cron{height:36px;border:1px solid var(--border);border-radius:8px;background:#151517;color:var(--ink);padding:0 8px;font:inherit}
|
||||
.sched-card{display:flex;flex-wrap:wrap;gap:8px;padding:10px;border:1px solid var(--border);border-radius:12px;background:var(--input)}
|
||||
.sched-card select,.sched-cron{height:36px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);padding:0 8px;font:inherit}
|
||||
.sched-cron{flex:1;min-width:140px;font-family:ui-monospace,monospace}
|
||||
.sched-list,.sched-editor{flex:none;overflow:visible;border-top:1px solid var(--line);padding-top:16px;margin-top:6px;min-width:0}
|
||||
.sched-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;padding:4px 0}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { t } from "./i18n";
|
||||
import { getLocale, t } from "./i18n";
|
||||
|
||||
export type CronFreq = "Every hour" | "Every day" | "Weekdays" | "Every week" | "Every month" | "Interval" | "Advanced";
|
||||
export type CronUnit = "minutes" | "hours" | "days";
|
||||
|
|
@ -31,7 +31,7 @@ export function cronFromPreset(input: CronPreset): string {
|
|||
if (input.freq === "Advanced") return input.cron.trim();
|
||||
if (input.freq === "Every hour") return "0 * * * *";
|
||||
if (input.freq === "Interval") {
|
||||
if (!Number.isInteger(input.n) || input.n < 1 || input.n > 365) throw new Error("間隔必須為 1–365 的整數");
|
||||
if (!Number.isInteger(input.n) || input.n < 1 || input.n > 365) throw new Error(t("schedInvalidInterval"));
|
||||
return `@every ${input.n}${({minutes:"m",hours:"h",days:"d"})[input.unit]}`;
|
||||
}
|
||||
const { hour, minute } = parseClock(input.time);
|
||||
|
|
@ -75,6 +75,59 @@ function formatClock(hour: number, minute: number): string {
|
|||
return `${h12}:${String(minute).padStart(2, "0")} ${period}`;
|
||||
}
|
||||
|
||||
export function clockLabel(time: string): string {
|
||||
const { hour, minute } = parseClock(time);
|
||||
if (getLocale() === "en") return formatClock(hour, minute);
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function describeCron(cron: string): string {
|
||||
const preset = presetFromCron(cron);
|
||||
const time = clockLabel(preset.time);
|
||||
if (preset.freq === "Every hour") return t("schedEveryHour");
|
||||
if (preset.freq === "Every day") return t("schedHumanDaily", { time });
|
||||
if (preset.freq === "Weekdays") return t("schedHumanWeekdays", { time });
|
||||
if (preset.freq === "Every week") return t("schedHumanWeekly", { time });
|
||||
if (preset.freq === "Every month") return t("schedHumanMonthly", { time });
|
||||
if (preset.freq === "Interval") {
|
||||
const unit =
|
||||
preset.unit === "hours" ? t("schedHours") : preset.unit === "days" ? t("schedDays") : t("schedMinutes");
|
||||
return t("schedHumanInterval", { n: preset.n, unit });
|
||||
}
|
||||
return cron;
|
||||
}
|
||||
|
||||
export function describeCronHuman(human: string): string {
|
||||
const text = human.trim();
|
||||
if (!text) return "";
|
||||
if (text === "每小時" || text === "Every hour") return t("schedEveryHour");
|
||||
if (text === "每分鐘" || text === "Every minute") return t("schedEveryMinute");
|
||||
let match: RegExpExecArray | null;
|
||||
if ((match = /^(?:每天|Daily at) (.+)$/.exec(text))) return t("schedHumanDaily", { time: clockLabel(match[1]) });
|
||||
if ((match = /^(?:工作日|Weekdays at) (.+)$/.exec(text))) return t("schedHumanWeekdays", { time: clockLabel(match[1]) });
|
||||
if ((match = /^(?:每週一|Mondays at) (.+)$/.exec(text))) return t("schedHumanWeekly", { time: clockLabel(match[1]) });
|
||||
if ((match = /^(?:每月 1 日|Monthly on the 1st at) (.+)$/.exec(text))) {
|
||||
return t("schedHumanMonthlyDay", { time: clockLabel(match[1]) });
|
||||
}
|
||||
if ((match = /^(?:每月|Monthly at) (.+)$/.exec(text))) return t("schedHumanMonthly", { time: clockLabel(match[1]) });
|
||||
if ((match = /^(?:每隔 (\d+) 分鐘(固定間隔)|Every (\d+) minutes \(elapsed\))$/.exec(text))) {
|
||||
return t("schedHumanElapsed", { n: match[1] || match[2] });
|
||||
}
|
||||
if ((match = /^(?:每 (\d+) 分鐘|Every (\d+) minutes)$/.exec(text))) {
|
||||
return t("schedHumanEveryMinutes", { n: match[1] || match[2] });
|
||||
}
|
||||
if ((match = /^(?:每 (\d+) 小時|Every (\d+) hours)$/.exec(text))) {
|
||||
return t("schedHumanEveryHours", { n: match[1] || match[2] });
|
||||
}
|
||||
if ((match = /^(?:日曆排程:|Calendar schedule: )(.+)$/.exec(text))) return t("schedCalendar", { expr: match[1] });
|
||||
return text;
|
||||
}
|
||||
|
||||
export function scheduleWhen(cron?: string, human?: string): string {
|
||||
if (cron) return describeCron(cron);
|
||||
return describeCronHuman(human || "");
|
||||
}
|
||||
|
||||
function freqLabel(freq: CronFreq): string {
|
||||
return ({
|
||||
"Every hour": t("schedEveryHour"),
|
||||
|
|
@ -112,7 +165,7 @@ export function ScheduleList({
|
|||
<i className={`sched-dot ${item.enabled ? "on" : "off"}`} />
|
||||
<span>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.enabled ? item.human : t("schedPaused")}</small>
|
||||
<small>{item.enabled ? describeCron(item.cron) : t("schedPaused")}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="outline" disabled={runningId === item.id} onClick={() => onRun(item)}>
|
||||
|
|
@ -179,7 +232,7 @@ export function ScheduleEditor({
|
|||
</>}
|
||||
{TIMED.includes(preset.freq) && (
|
||||
<select value={preset.time} onChange={e => patchPreset({ time: e.target.value })}>
|
||||
{times.map(time => <option key={time} value={time}>{time}</option>)}
|
||||
{times.map(time => <option key={time} value={time}>{clockLabel(time)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{preset.freq === "Advanced" && (
|
||||
|
|
@ -187,7 +240,7 @@ export function ScheduleEditor({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preset.freq === "Interval" && <small className="memory-help">固定經過時間;一天為 24 小時,不因月底或日光節約時間重置。</small>}
|
||||
{preset.freq === "Interval" && <small className="memory-help">{t("schedIntervalHint")}</small>}
|
||||
{error && <div className="pane-error">{error}</div>}
|
||||
<div className="pane-actions">
|
||||
{onDelete && <button type="button" className="danger" disabled={saving} onClick={onDelete}>{t("delete")}</button>}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
:root{font-family:"Huninn","jf open 粉圓",sans-serif;color:#dfdfe2;background:#050506;font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71}
|
||||
:root{font-family:"Huninn","jf open 粉圓",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71;--hairline:#171719;--elevated:#121214;--menu:#18181b;--input:#0c0c0d;--dialog:#111113;--on-cream:#1a1a1a;--on-send:#1b1b1c;--online:#22c55e;--unread:#3b82f6;--surface-hover:rgba(255,255,255,.06);--focus:#4b4b50;--error-bg:#2a1717;--error-fg:#fca5a5;--error-border:#5a2a2a;--danger-fill:#8f2828;--danger-line:#a43b3b;--danger-soft:#ff8585;--warn-bg:#221c0e;--warn-fg:#fcd68a;--warn-border:#5a4a1f}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ button{color:inherit}
|
|||
|
||||
.app-shell{height:100%;display:grid;grid-template-columns:280px minmax(420px,1fr) minmax(340px,420px);background:var(--page);overflow:hidden}
|
||||
|
||||
.sidebar,.chat-panel,.computer-panel{min-width:0;min-height:0;border-right:1px solid #171719}
|
||||
.sidebar,.chat-panel,.computer-panel{min-width:0;min-height:0;border-right:1px solid var(--hairline)}
|
||||
|
||||
.sidebar{display:flex;flex-direction:column;background:var(--side);padding:14px}
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ button{color:inherit}
|
|||
|
||||
.icon-button svg{width:18px;height:18px}
|
||||
|
||||
.search{height:44px;display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:14px;background:#121214;padding:0 13px;color:var(--muted);margin:10px 5px 16px}
|
||||
.search{height:44px;display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:14px;background:var(--elevated);padding:0 13px;color:var(--muted);margin:10px 5px 16px}
|
||||
|
||||
.search svg{width:15px}
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ button{color:inherit}
|
|||
|
||||
.bot-row{display:flex;align-items:center;gap:12px;width:100%;padding:11px;border:0;border-radius:13px;background:transparent;text-align:left;cursor:pointer}
|
||||
|
||||
.bot-row:hover,.bot-row.selected{background:#171719}
|
||||
.bot-row:hover,.bot-row.selected{background:var(--hairline)}
|
||||
|
||||
.bot-row>span:nth-child(2){min-width:0;display:grid;gap:2px}
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ button{color:inherit}
|
|||
|
||||
.bot-row small{color:var(--muted)}
|
||||
|
||||
.sidebar-bottom{margin-top:auto;border-top:1px solid #171719;padding-top:12px}
|
||||
.sidebar-bottom{margin-top:auto;border-top:1px solid var(--hairline);padding-top:12px}
|
||||
|
||||
.account{width:100%;display:flex;align-items:center;gap:10px;padding:8px;border:0;background:transparent;border-radius:12px}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ button{color:inherit}
|
|||
|
||||
.chat-panel{position:relative;display:flex;flex-direction:column;background:var(--main)}
|
||||
|
||||
.topbar{height:72px;flex:0 0 72px;padding:0 24px;gap:12px;border-bottom:1px solid #171719}
|
||||
.topbar{height:72px;flex:0 0 72px;padding:0 24px;gap:12px;border-bottom:1px solid var(--hairline)}
|
||||
|
||||
.grow{flex:1}
|
||||
|
||||
|
|
@ -74,11 +74,11 @@ button{color:inherit}
|
|||
|
||||
.composer-plus,.send{width:42px;height:42px;border:0;border-radius:50%;display:grid;place-items:center;cursor:pointer}
|
||||
|
||||
.send{background:var(--cream);color:#1b1b1c}
|
||||
.send{background:var(--cream);color:var(--on-send)}
|
||||
|
||||
.send:disabled{opacity:.28}
|
||||
|
||||
.error-banner{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:80%;padding:10px 14px;border:1px solid #5a2a2a;border-radius:10px;background:#2a1717;color:#fca5a5;font-size:13px}
|
||||
.error-banner{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:80%;padding:10px 14px;border:1px solid var(--error-border);border-radius:10px;background:var(--error-bg);color:var(--error-fg);font-size:13px}
|
||||
|
||||
.error-banner button{border:0;background:none;color:inherit}
|
||||
|
||||
|
|
@ -88,15 +88,15 @@ button{color:inherit}
|
|||
|
||||
.panel-head>span{color:var(--ink);font-weight:600;margin-right:auto}
|
||||
|
||||
.preview{aspect-ratio:16/10;width:100%;overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#101012}
|
||||
.preview{aspect-ratio:16/10;width:100%;overflow:hidden;border:1px solid var(--border);border-radius:14px;background:var(--inset)}
|
||||
|
||||
.primary,.outline,.danger{min-height:36px;border-radius:10px;padding:0 15px;border:1px solid var(--border);cursor:pointer;display:inline-flex;align-items:center;gap:7px}
|
||||
|
||||
.primary{background:var(--cream);border-color:var(--cream);color:#181819;font-weight:600}
|
||||
.primary{background:var(--cream);border-color:var(--cream);color:var(--on-cream);font-weight:600}
|
||||
|
||||
.outline{background:transparent}
|
||||
|
||||
.danger{background:#8f2828;border-color:#a43b3b;color:#fff}
|
||||
.danger{background:var(--danger-fill);border-color:var(--danger-line);color:#fff}
|
||||
|
||||
.primary:disabled,.outline:disabled{opacity:.35;cursor:not-allowed}
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ button{color:inherit}
|
|||
|
||||
.modal-backdrop{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;background:rgba(0,0,0,.68);backdrop-filter:blur(8px)}
|
||||
|
||||
.dialog{width:min(520px,100%);display:grid;gap:20px;border:1px solid var(--border);border-radius:18px;background:#111113;padding:22px;box-shadow:0 24px 80px #000}
|
||||
.dialog{width:min(520px,100%);display:grid;gap:20px;border:1px solid var(--border);border-radius:18px;background:var(--dialog);padding:22px;box-shadow:0 24px 80px #000}
|
||||
|
||||
.dialog.compact{width:min(430px,100%)}
|
||||
|
||||
|
|
@ -120,13 +120,13 @@ button{color:inherit}
|
|||
|
||||
.dialog label{display:grid;gap:8px;color:var(--muted);font-size:13px}
|
||||
|
||||
.dialog input{height:44px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
|
||||
.dialog input,.dialog select{height:44px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:0 12px;outline:0}
|
||||
|
||||
.dialog input:focus{border-color:#4b4b50}
|
||||
.dialog input:focus,.dialog select:focus{border-color:var(--focus)}
|
||||
|
||||
.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
|
||||
.mode-grid button{display:grid;justify-items:start;gap:7px;padding:16px;border:1px solid var(--border);border-radius:13px;background:#0c0c0d;text-align:left}
|
||||
.mode-grid button{display:grid;justify-items:start;gap:7px;padding:16px;border:1px solid var(--border);border-radius:13px;background:var(--input);text-align:left}
|
||||
|
||||
.mode-grid button.picked{border-color:var(--accent);background:rgba(62,197,168,.07)}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface PlaybookInput { name:string; description?:string; example?:stri
|
|||
export interface Playbook { name?:string; whenToUse?:string; intent?:string; inputs?:PlaybookInput[]; preconditions?:string[]; steps?:(PlaybookStep|string)[]; howToCheck?:string; whatToReturn?:string; cautions?:string[] }
|
||||
export type TaughtSkillStatus = "recording"|"drafting"|"draft"|"saved"|"failed"|"cancelled";
|
||||
export interface TaughtSkill { id:string; botId:string; threadId:string|null; name:string; goal:string; status:TaughtSkillStatus; playbook:Playbook; error:string|null; startedAt:string|null; expiresAt:string|null; stoppedAt:string|null; createdAt:string; updatedAt:string; eventCount:number; frameCount:number }
|
||||
export interface FileSkill { name:string; description:string }
|
||||
export interface MemoryItem { id:string; sessionId:string|null; sourceRunId:string|null; sourceMessageId:string|null; content:string; importance:number; revision:number; createdAt:string; updatedAt:string }
|
||||
export type McpTransport = "stdio" | "http" | "sse";
|
||||
export interface McpTool { name:string; exposedName:string; description:string }
|
||||
|
|
@ -22,6 +23,22 @@ export interface McpServer { id:string; name:string; transport:McpTransport; com
|
|||
export interface McpSecretField { name:string; required:boolean; secret:boolean; hint:string }
|
||||
export interface McpCatalogEntry { id:string; title:string; description:string; transport:McpTransport; command:string|null; args:string[]; url:string|null; envKeys:McpSecretField[]; headerKeys:McpSecretField[]; source:"featured"|"registry"; remote:boolean }
|
||||
export type ModelProviderId = "xai" | "opencode-go" | "openai-compatible";
|
||||
export type VoiceProviderId = "xai" | "openai" | "scripted";
|
||||
export interface VoiceSettings {
|
||||
enabled: boolean;
|
||||
provider: VoiceProviderId;
|
||||
modelId: string;
|
||||
voiceId: string;
|
||||
ready: boolean;
|
||||
missing?: string | null;
|
||||
apiKeySet: boolean;
|
||||
envKeySet: boolean;
|
||||
envKeyName: string;
|
||||
reusesTextKey: boolean;
|
||||
providers: { id: VoiceProviderId; name: string; envKeyName: string }[];
|
||||
models: { id: string; name: string }[];
|
||||
voices: { id: string; name: string }[];
|
||||
}
|
||||
export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
|
||||
export interface WorkspaceModel { id:string; name:string }
|
||||
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; envKeySet:boolean; envKeyName:string; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { t } from "./i18n";
|
||||
import { X } from "./animated-icons";
|
||||
import type { VoiceProviderId, VoiceSettings } from "./types";
|
||||
|
||||
export type { VoiceProviderId, VoiceSettings };
|
||||
|
||||
export function VoiceSettingsDialog({ close }: { close: () => void }) {
|
||||
const [settings, setSettings] = useState<VoiceSettings | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [provider, setProvider] = useState<VoiceProviderId>("xai");
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [voiceId, setVoiceId] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [clearKey, setClearKey] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api<VoiceSettings>("/api/voice/settings")
|
||||
.then((value) => {
|
||||
setSettings(value);
|
||||
setEnabled(value.enabled);
|
||||
setProvider(value.provider);
|
||||
setModelId(value.modelId);
|
||||
setVoiceId(value.voiceId);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : t("loadFailed")));
|
||||
}, []);
|
||||
|
||||
const current = settings?.providers.find((item) => item.id === provider);
|
||||
const models = (settings?.provider === provider ? settings.models : null)
|
||||
?? (provider === "openai"
|
||||
? [{ id: "gpt-realtime", name: "GPT Realtime" }]
|
||||
: [{ id: "grok-voice-latest", name: "Grok Voice Latest" }, { id: "grok-voice-think-fast-2.0", name: "Grok Voice Think Fast 2.0" }]);
|
||||
const voices = (settings?.provider === provider ? settings.voices : null)
|
||||
?? (provider === "openai"
|
||||
? [{ id: "marin", name: "Marin" }, { id: "alloy", name: "Alloy" }, { id: "verse", name: "Verse" }]
|
||||
: [{ id: "eve", name: "Eve" }, { id: "ara", name: "Ara" }, { id: "leo", name: "Leo" }, { id: "rex", name: "Rex" }, { id: "sal", name: "Sal" }]);
|
||||
|
||||
function pickProvider(id: VoiceProviderId) {
|
||||
setProvider(id);
|
||||
setError("");
|
||||
if (id === "openai") {
|
||||
setModelId("gpt-realtime");
|
||||
setVoiceId("marin");
|
||||
return;
|
||||
}
|
||||
setModelId("grok-voice-latest");
|
||||
setVoiceId("eve");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<form
|
||||
className="dialog settings-dialog"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api("/api/voice/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
provider,
|
||||
modelId: modelId.trim(),
|
||||
voiceId: voiceId.trim(),
|
||||
apiKey: clearKey ? "" : apiKey.trim() || null,
|
||||
clearApiKey: clearKey,
|
||||
}),
|
||||
});
|
||||
close();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settingsFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="dialog-title">
|
||||
<h2>{t("voiceSettings")}</h2>
|
||||
<button type="button" onClick={close}><X /></button>
|
||||
</div>
|
||||
<p className="dialog-lead">{t("voiceSettingsHint")}</p>
|
||||
<label className="memory-toggle">
|
||||
<input type="checkbox" role="switch" checked={enabled} disabled={!settings || busy} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
{t("voiceEnabled")}
|
||||
</label>
|
||||
<p className="dialog-lead">{t("voiceEnabledHint")}</p>
|
||||
{enabled ? <>
|
||||
<fieldset className="provider-fieldset">
|
||||
<legend>{t("voiceProvider")}</legend>
|
||||
<div className="provider-grid">
|
||||
{(settings?.providers || [
|
||||
{ id: "xai" as const, name: t("providerXai"), envKeyName: "XAI_API_KEY" },
|
||||
{ id: "openai" as const, name: t("providerOpenai"), envKeyName: "OPENAI_API_KEY" },
|
||||
]).map((item) => (
|
||||
<button type="button" key={item.id} className={provider === item.id ? "picked" : ""} onClick={() => pickProvider(item.id)}>
|
||||
{item.id === "xai" ? t("providerXai") : item.id === "openai" ? t("providerOpenai") : item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="dialog-lead">{provider === "openai" ? t("providerOpenaiVoiceHint") : t("providerXaiVoiceHint")}</p>
|
||||
</fieldset>
|
||||
<label>
|
||||
{t("voiceModel")}
|
||||
{models.length ? (
|
||||
<select value={models.some((item) => item.id === modelId) ? modelId : modelId} onChange={(event) => setModelId(event.target.value)}>
|
||||
{models.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
{!models.some((item) => item.id === modelId) && modelId ? <option value={modelId}>{modelId}</option> : null}
|
||||
</select>
|
||||
) : (
|
||||
<input value={modelId} onChange={(event) => setModelId(event.target.value)} />
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
{t("voiceName")}
|
||||
{voices.length ? (
|
||||
<select value={voiceId} onChange={(event) => setVoiceId(event.target.value)}>
|
||||
{voices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={voiceId} onChange={(event) => setVoiceId(event.target.value)} />
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
{t("apiKey")}
|
||||
<input type="password" autoComplete="off" value={apiKey} onChange={(event) => { setApiKey(event.target.value); setClearKey(false); }} placeholder={settings?.apiKeySet ? t("apiKeyStored") : t("apiKeyPlaceholder")} />
|
||||
</label>
|
||||
{settings?.reusesTextKey && !apiKey && !clearKey ? <p className="dialog-lead">{t("voiceReusesTextKey")}</p> : null}
|
||||
{settings?.envKeySet && !apiKey && !clearKey ? <p className="dialog-lead">{t("usingEnvKey", { name: current?.envKeyName || settings.envKeyName })}</p> : null}
|
||||
{settings?.apiKeySet ? <label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={(event) => setClearKey(event.target.checked)} /> {t("clearApiKey")}</label> : null}
|
||||
</> : null}
|
||||
{error ? <div className="pane-error">{error}</div> : null}
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="outline" onClick={close}>{t("cancel")}</button>
|
||||
<button className="primary" disabled={busy || !settings || !modelId.trim() || !voiceId.trim()}>{busy ? t("saving") : t("save")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({plugins:[react()],build:{outDir:"dist",emptyOutDir:true},server:{port:5173,proxy:{"/api":"http://127.0.0.1:3101","/view":{target:"http://127.0.0.1:3101",ws:true}}}});
|
||||
export default defineConfig({plugins:[react()],build:{outDir:"dist",emptyOutDir:true},server:{port:5173,proxy:{"/api":{target:"http://127.0.0.1:3101",ws:true},"/view":{target:"http://127.0.0.1:3101",ws:true}}}});
|
||||
|
|
|
|||
|
|
@ -722,9 +722,12 @@ pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
|
|||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "computer not found".to_string())?;
|
||||
if computer_has_active_work(state, &computer_id).await {
|
||||
return Err("電腦仍有工作或示範進行中,請先停止工作再關閉電腦。".into());
|
||||
}
|
||||
if let Some(provider_ref) = &computer.provider_ref {
|
||||
let ctx = adapter_context(actor, bot_id, "stop");
|
||||
let _ = state
|
||||
state
|
||||
.sandbox
|
||||
.stop(
|
||||
&lazyboy_control::ComputerRef {
|
||||
|
|
@ -736,7 +739,8 @@ pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
|
|||
},
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.map_err(|error| format!("無法關閉電腦:{error}"))?;
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE computers SET state = 'stopped', control_holder = 'none', control_lease_id = NULL,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ pub struct SpaceRow {
|
|||
pub default_model_id: String,
|
||||
pub default_model_base_url: Option<String>,
|
||||
pub default_model_api_key: Option<String>,
|
||||
pub voice_enabled: bool,
|
||||
pub voice_provider: Option<String>,
|
||||
pub voice_model_id: Option<String>,
|
||||
pub voice_id: Option<String>,
|
||||
pub voice_api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
|
|
@ -125,7 +130,8 @@ impl Db {
|
|||
pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
"SELECT id, user_id, name, default_model_provider, default_model_id,
|
||||
default_model_base_url, default_model_api_key
|
||||
default_model_base_url, default_model_api_key,
|
||||
voice_enabled, voice_provider, voice_model_id, voice_id, voice_api_key
|
||||
FROM spaces WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(&actor.space_id)
|
||||
|
|
@ -178,6 +184,51 @@ impl Db {
|
|||
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
|
||||
}
|
||||
|
||||
pub async fn update_voice_settings(
|
||||
&self,
|
||||
actor: &Actor,
|
||||
enabled: Option<bool>,
|
||||
provider: &str,
|
||||
model_id: &str,
|
||||
voice_id: &str,
|
||||
api_key: Option<Option<&str>>,
|
||||
) -> Result<SpaceRow, sqlx::Error> {
|
||||
match api_key {
|
||||
Some(key) => {
|
||||
sqlx::query(
|
||||
"UPDATE spaces
|
||||
SET voice_provider = $3, voice_model_id = $4, voice_id = $5, voice_api_key = $6, voice_enabled = COALESCE($7, voice_enabled)
|
||||
WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(&actor.space_id)
|
||||
.bind(&actor.user_id)
|
||||
.bind(provider)
|
||||
.bind(model_id)
|
||||
.bind(voice_id)
|
||||
.bind(key)
|
||||
.bind(enabled)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
sqlx::query(
|
||||
"UPDATE spaces
|
||||
SET voice_provider = $3, voice_model_id = $4, voice_id = $5, voice_enabled = COALESCE($6, voice_enabled)
|
||||
WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(&actor.space_id)
|
||||
.bind(&actor.user_id)
|
||||
.bind(provider)
|
||||
.bind(model_id)
|
||||
.bind(voice_id)
|
||||
.bind(enabled)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
|
||||
}
|
||||
|
||||
pub async fn list_bots(
|
||||
&self,
|
||||
actor: &Actor,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
//! Read-only, workspace-wide SKILL.md commands.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileSkill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name != "goal"
|
||||
&& name != "skills"
|
||||
&& name != "help"
|
||||
&& name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
fn parse_file(path: PathBuf, name: String) -> Option<FileSkill> {
|
||||
let file_type = std::fs::symlink_metadata(&path).ok()?.file_type();
|
||||
if file_type.is_symlink() || !file_type.is_file() {
|
||||
return None;
|
||||
}
|
||||
if std::fs::metadata(&path).ok()?.len() > 512 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let (frontmatter, instructions) = if let Some(rest) = text.strip_prefix("---\n") {
|
||||
let (header, body) = rest.split_once("\n---")?;
|
||||
(
|
||||
header,
|
||||
body.trim_start_matches(|ch| ch == '\n' || ch == '\r')
|
||||
.trim()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
("", text.trim().to_string())
|
||||
};
|
||||
let description = frontmatter.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
(key.trim() == "description")
|
||||
.then(|| value.trim().trim_matches(|ch| ch == '"' || ch == '\'').to_string())
|
||||
}).unwrap_or_else(|| instructions.lines().next().unwrap_or("自訂技能").chars().take(120).collect());
|
||||
(!instructions.is_empty()).then_some(FileSkill { name, description, instructions })
|
||||
}
|
||||
|
||||
pub fn list(data_dir: &str) -> Vec<FileSkill> {
|
||||
let root = Path::new(data_dir).join("skills");
|
||||
let Ok(entries) = std::fs::read_dir(&root) else { return Vec::new() };
|
||||
let mut skills = entries.filter_map(Result::ok).filter_map(|entry| {
|
||||
let file_type = entry.file_type().ok()?;
|
||||
if !file_type.is_dir() { return None; }
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
valid_name(&name).then(|| parse_file(entry.path().join("SKILL.md"), name)).flatten()
|
||||
}).collect::<Vec<_>>();
|
||||
skills.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn slash(prompt: &str, data_dir: &str) -> Option<(FileSkill, String)> {
|
||||
let mut words = prompt.trim().splitn(2, char::is_whitespace);
|
||||
let command = words.next()?.strip_prefix('/')?;
|
||||
if !valid_name(command) { return None; }
|
||||
let skill = list(data_dir).into_iter().find(|skill| skill.name == command)?;
|
||||
Some((skill, words.next().unwrap_or("").trim().to_string()))
|
||||
}
|
||||
|
||||
pub fn index(data_dir: &str) -> String {
|
||||
let skills = list(data_dir);
|
||||
if skills.is_empty() { return String::new(); }
|
||||
let lines = skills.iter().map(|skill| format!("/{:<18} {}", skill.name, skill.description)).collect::<Vec<_>>().join("\n");
|
||||
format!("可用的檔案技能(唯讀):\n{lines}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn loads_only_safe_skill_names_and_resolves_arguments() {
|
||||
let root = std::env::temp_dir().join(format!("lazyboy-file-skills-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join("skills/open-site")).unwrap();
|
||||
fs::write(root.join("skills/open-site/SKILL.md"), "---\ndescription: Open a site\n---\nUse the browser.\n").unwrap();
|
||||
fs::create_dir_all(root.join("skills/goal")).unwrap();
|
||||
let root_text = root.to_str().unwrap();
|
||||
assert_eq!(slash("/open-site example.com", root_text).unwrap().1, "example.com");
|
||||
assert!(slash("/goal do it", root_text).is_none());
|
||||
assert!(slash("/missing", root_text).is_none());
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ mod attachments;
|
|||
mod auth;
|
||||
mod computer;
|
||||
mod db;
|
||||
mod file_skills;
|
||||
mod mcp;
|
||||
mod mcp_catalog;
|
||||
mod memory;
|
||||
mod retention;
|
||||
mod rooms;
|
||||
mod routes;
|
||||
mod runs;
|
||||
|
|
@ -15,15 +17,17 @@ mod skills;
|
|||
mod state;
|
||||
mod tools;
|
||||
mod vault;
|
||||
mod voice;
|
||||
mod voice_call;
|
||||
mod web_static;
|
||||
mod workspace;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use state::AppState;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -51,6 +55,9 @@ async fn main() {
|
|||
mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
|
||||
});
|
||||
|
||||
let retention_state = state.clone();
|
||||
tokio::spawn(async move { retention::retention_loop(retention_state).await; });
|
||||
|
||||
let worker_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
runs::worker_loop(worker_state).await;
|
||||
|
|
@ -73,14 +80,13 @@ async fn main() {
|
|||
}
|
||||
|
||||
let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web/dist".into());
|
||||
let app = Router::new()
|
||||
let app = web_static::static_router(&web_dir)
|
||||
.route(
|
||||
"/api/health",
|
||||
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
|
||||
)
|
||||
.merge(auth::public_router(state.clone()))
|
||||
.merge(routes::router(state.clone()))
|
||||
.fallback_service(ServeDir::new(web_dir))
|
||||
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
//! Bounded maintenance of expendable diagnostics, never user-authored content.
|
||||
use std::time::{Duration};
|
||||
use sqlx::PgPool;
|
||||
use crate::state::AppState;
|
||||
|
||||
fn days(name: &str, default: i32) -> i32 {
|
||||
std::env::var(name).ok().and_then(|v| v.parse::<i32>().ok())
|
||||
.filter(|v| (1..=3650).contains(v)).unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn retention_loop(state: AppState) {
|
||||
let rules = [
|
||||
("events", include_str!("retention/events.sql"), days("LAZYBOY_EVENT_RETENTION_DAYS", 30)),
|
||||
("checkpoints", include_str!("retention/checkpoints.sql"), days("LAZYBOY_CHECKPOINT_RETENTION_DAYS", 7)),
|
||||
("runs", include_str!("retention/runs.sql"), days("LAZYBOY_RUN_RETENTION_DAYS", 90)),
|
||||
("recordings", include_str!("retention/recordings.sql"), days("LAZYBOY_RECORDING_RETENTION_DAYS", 30)),
|
||||
("revisions", include_str!("retention/revisions.sql"), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90)),
|
||||
("deleted_memories", include_str!("retention/deleted_memories.sql"), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90)),
|
||||
("leases", include_str!("retention/leases.sql"), 7),
|
||||
("profile_locks", include_str!("retention/profile_locks.sql"), 7),
|
||||
];
|
||||
loop {
|
||||
for (name, query, age) in rules {
|
||||
let mut removed = 0;
|
||||
// Limit both transaction size and work per hour; defer excess backlog.
|
||||
for _ in 0..20 {
|
||||
match batch(state.pool(), query, age).await {
|
||||
Ok(count) => { removed += count; if count < 1000 { break; } }
|
||||
Err(error) => { tracing::warn!(name, %error, "retention batch failed; retry next hour"); break; }
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
if removed > 0 { tracing::info!(name, rows=removed, "retention cleaned expired diagnostics"); }
|
||||
}
|
||||
if let Err(error) = clean_frames(&state, rules[3].2).await {
|
||||
tracing::warn!(%error, "recording file retention failed; retry next hour");
|
||||
}
|
||||
if let Ok(bytes) = sqlx::query_scalar::<_, i64>("SELECT pg_database_size(current_database())").fetch_one(state.pool()).await {
|
||||
let warn_mb = std::env::var("LAZYBOY_DB_WARN_MB").ok().and_then(|v| v.parse::<i64>().ok()).filter(|v| *v > 0 && *v < 1_000_000).unwrap_or(1024);
|
||||
tracing::info!(bytes, "database size after retention");
|
||||
if bytes > warn_mb * 1024 * 1024 { tracing::warn!(bytes, warn_mb, "database exceeds configured size warning; review retained conversations and memories"); }
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn batch(pool: &PgPool, query: &str, age: i32) -> Result<u64, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
// One maintenance writer, even when multiple API processes start together.
|
||||
let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_xact_lock(72189431)").fetch_one(&mut *tx).await?;
|
||||
if !acquired { return Ok(0); }
|
||||
sqlx::query("SET LOCAL statement_timeout = '10s'").execute(&mut *tx).await?;
|
||||
sqlx::query("SET LOCAL lock_timeout = '1s'").execute(&mut *tx).await?;
|
||||
let result = sqlx::query(query).bind(age).bind(1000_i64).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn clean_frames(state: &AppState, age: i32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let root = std::path::Path::new(&state.data_dir).join("teach");
|
||||
let mut dirs = match tokio::fs::read_dir(&root).await {
|
||||
Ok(dirs) => dirs,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut cleaned = 0;
|
||||
while let Some(entry) = dirs.next_entry().await? {
|
||||
if cleaned >= 1000 { break; }
|
||||
// Ignore symlinks and unexpected names; never traverse a user's home.
|
||||
if !entry.file_type().await?.is_dir() { continue; }
|
||||
let id = entry.file_name().to_string_lossy().into_owned();
|
||||
if uuid::Uuid::parse_str(&id).is_err() { continue; }
|
||||
let eligible: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT status IN ('saved','failed','draft') AND updated_at < now() - make_interval(days => $2) FROM taught_skills WHERE id=$1"
|
||||
).bind(&id).bind(age).fetch_optional(state.pool()).await?;
|
||||
let old_orphan = eligible.is_none() && entry.metadata().await?.modified()?.elapsed().unwrap_or(Duration::ZERO) > Duration::from_secs(age as u64 * 86400);
|
||||
if eligible == Some(true) || old_orphan {
|
||||
tokio::fs::remove_dir_all(entry.path()).await?;
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
if cleaned > 0 { tracing::info!(cleaned, "removed expired teaching frame directories"); }
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
UPDATE runs SET checkpoint = '{}'::jsonb WHERE id IN (
|
||||
SELECT id FROM runs WHERE status IN ('completed','failed','cancelled')
|
||||
AND COALESCE(completed_at,updated_at) < now() - make_interval(days => $1)
|
||||
AND checkpoint <> '{}'::jsonb
|
||||
ORDER BY COALESCE(completed_at,updated_at) LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
DELETE FROM memory_items WHERE id IN (
|
||||
SELECT id FROM memory_items WHERE deleted_at < now() - make_interval(days => $1)
|
||||
ORDER BY deleted_at LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
DELETE FROM events WHERE id IN (
|
||||
SELECT id FROM events WHERE created_at < now() - make_interval(days => $1)
|
||||
ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
DELETE FROM computer_execution_leases WHERE id IN (
|
||||
SELECT l.id FROM computer_execution_leases l WHERE l.expires_at < now() - make_interval(days => $1)
|
||||
AND NOT EXISTS (SELECT 1 FROM runs r WHERE r.id=l.run_id AND r.status NOT IN ('completed','failed','cancelled'))
|
||||
ORDER BY l.expires_at LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
DELETE FROM computer_profile_locks WHERE (computer_id,profile_key) IN (
|
||||
SELECT l.computer_id,l.profile_key FROM computer_profile_locks l
|
||||
WHERE l.expires_at < now() - make_interval(days => $1)
|
||||
AND NOT EXISTS (SELECT 1 FROM runs r WHERE r.id=l.run_id AND r.status NOT IN ('completed','failed','cancelled'))
|
||||
ORDER BY l.expires_at LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
UPDATE taught_skills SET recording = '{}'::jsonb WHERE id IN (
|
||||
SELECT id FROM taught_skills WHERE status IN ('saved','failed','draft')
|
||||
AND updated_at < now() - make_interval(days => $1) AND recording <> '{}'::jsonb
|
||||
ORDER BY updated_at LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
DELETE FROM memory_revisions WHERE (memory_id,revision) IN (
|
||||
SELECT r.memory_id,r.revision FROM memory_revisions r JOIN memory_items m ON m.id=r.memory_id
|
||||
WHERE r.revision < m.revision AND (
|
||||
r.created_at < now() - make_interval(days => $1)
|
||||
OR r.revision <= m.revision - 10
|
||||
) ORDER BY r.created_at LIMIT $2 FOR UPDATE OF r SKIP LOCKED
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
DELETE FROM runs WHERE id IN (
|
||||
SELECT id FROM runs WHERE status IN ('completed','failed','cancelled')
|
||||
AND COALESCE(completed_at,updated_at) < now() - make_interval(days => $1)
|
||||
ORDER BY COALESCE(completed_at,updated_at) LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
|
@ -18,7 +18,9 @@ pub fn router(state: AppState) -> Router {
|
|||
.merge(crate::rooms::router())
|
||||
.merge(crate::mcp::router())
|
||||
.merge(crate::workspace::router())
|
||||
.merge(crate::voice::router())
|
||||
.merge(crate::skills::router())
|
||||
.route("/api/file-skills", get(file_skills))
|
||||
.merge(crate::vault::router())
|
||||
.merge(crate::schedules::router())
|
||||
.route("/api/bots", get(list_bots).post(create_bot))
|
||||
|
|
@ -51,6 +53,10 @@ pub fn router(state: AppState) -> Router {
|
|||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn file_skills(State(state): State<AppState>) -> Json<Vec<crate::file_skills::FileSkill>> {
|
||||
Json(crate::file_skills::list(&state.data_dir))
|
||||
}
|
||||
|
||||
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
|
||||
state
|
||||
.bootstrap()
|
||||
|
|
@ -602,12 +608,12 @@ async fn restart(
|
|||
async fn stop(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let actor = actor(&state).await?;
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"無法取得工作區"}))))?;
|
||||
computer::stop(&state, &actor, &id)
|
||||
.await
|
||||
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message":error}))))
|
||||
}
|
||||
|
||||
async fn screen_url(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use lazyboy_harness::execution::{ExecutionMode, GoalOutcome, goal_request, goal_outcome, GOAL_INSTRUCTIONS, GOAL_CONTINUE};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -130,7 +131,31 @@ pub async fn send(
|
|||
}));
|
||||
}
|
||||
}
|
||||
let run_id = Uuid::new_v4().to_string();
|
||||
// A message sent while a single-bot /goal is active is steering for that
|
||||
// run. Reuse its id so it is delivered by the persistent loop rather than
|
||||
// creating a duplicate queued run that would repeat the work afterwards.
|
||||
let merged_goal_run: Option<String> = if room_id.is_none() {
|
||||
sqlx::query_scalar(
|
||||
"SELECT id FROM runs
|
||||
WHERE bot_id=$1 AND thread_id=$2
|
||||
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
||||
AND btrim(prompt) ~ '^/goal($|[[:space:]])'
|
||||
ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.bind(bot_id)
|
||||
.bind(thread_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let merged_goal = merged_goal_run.is_some();
|
||||
let run_id = merged_goal_run.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
if merged_goal {
|
||||
sqlx::query("UPDATE runs SET status='queued', retry_count=0, updated_at=now() WHERE id=$1 AND status='waiting_input'")
|
||||
.bind(&run_id).execute(&mut *tx).await.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
let seq: i32 = sqlx::query_scalar(
|
||||
"UPDATE threads
|
||||
|
|
@ -176,6 +201,9 @@ pub async fn send(
|
|||
member_ids.push(bot_id.to_string());
|
||||
}
|
||||
for (index, member_id) in member_ids.iter().enumerate() {
|
||||
if merged_goal && index == 0 {
|
||||
continue;
|
||||
}
|
||||
let member_run = if index == 0 {
|
||||
run_id.clone()
|
||||
} else {
|
||||
|
|
@ -213,7 +241,10 @@ pub async fn send(
|
|||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let queued_behind_active: bool = sqlx::query_scalar(
|
||||
let queued_behind_active = if merged_goal {
|
||||
true
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2
|
||||
AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))",
|
||||
)
|
||||
|
|
@ -221,7 +252,8 @@ pub async fn send(
|
|||
.bind(&run_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
.map_err(|error| error.to_string())?
|
||||
};
|
||||
if let Some(room_id) = room_id.as_deref() {
|
||||
sqlx::query("UPDATE rooms SET updated_at=now() WHERE id=$1")
|
||||
.bind(room_id)
|
||||
|
|
@ -508,12 +540,31 @@ async fn execute_run(
|
|||
});
|
||||
}
|
||||
}
|
||||
let goal_mode = goal_request(prompt).is_some();
|
||||
let goal_text = prompt
|
||||
.trim()
|
||||
.strip_prefix("/goal")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let file_skill = if !resume_after_takeover && !goal_mode {
|
||||
crate::file_skills::slash(prompt, &state.data_dir)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let initial_prompt = if goal_mode {
|
||||
format!("Execute this goal until it is verified complete:\n{}", goal_text)
|
||||
} else if let Some((skill, args)) = &file_skill {
|
||||
format!("Run the /{} skill with these arguments: {}", skill.name, args)
|
||||
} else {
|
||||
prompt.to_string()
|
||||
};
|
||||
let mut first = if resume_after_takeover {
|
||||
vec![UserContent::text(
|
||||
"The user finished collaborating and released control. Continue the original task from the CURRENT screen. Do not restart from scratch.",
|
||||
)]
|
||||
} else {
|
||||
vec![UserContent::text(prompt)]
|
||||
vec![UserContent::text(initial_prompt)]
|
||||
};
|
||||
let blocks: Vec<Value> = if resume_after_takeover {
|
||||
Vec::new()
|
||||
|
|
@ -537,12 +588,19 @@ async fn execute_run(
|
|||
if !resume_after_takeover {
|
||||
// The user named a taught skill: hand the model the full playbook up
|
||||
// front so it does not have to guess or call use_skill first.
|
||||
if let Some(skill) = crate::skills::skill_for_prompt(state.pool(), bot_id, prompt).await {
|
||||
if let Some(skill) = if goal_mode || file_skill.is_some() { None } else { crate::skills::skill_for_prompt(state.pool(), bot_id, prompt).await } {
|
||||
first.push(UserContent::text(crate::skills::format_playbook_for_run(
|
||||
&skill,
|
||||
)));
|
||||
skill_check = Some(crate::skills::skill_check_hint(&skill));
|
||||
}
|
||||
if let Some((skill, args)) = &file_skill {
|
||||
first.push(UserContent::text(format!(
|
||||
"File skill /{} (read-only source, follow its instructions):\n{}\nArguments: {}",
|
||||
skill.name, skill.instructions, args
|
||||
)));
|
||||
skill_check = Some(format!("完成 /{} 的指令,並用工具驗證結果。", skill.name));
|
||||
}
|
||||
}
|
||||
let mut earlier_replies: Vec<String> = assistant_texts(&history);
|
||||
if skill_check.is_some() {
|
||||
|
|
@ -560,7 +618,7 @@ async fn execute_run(
|
|||
// Greetings and small talk must not even *see* desktop tools: models
|
||||
// otherwise "check the screen" or `ls` the home on "hi" and boot Docker.
|
||||
let chat_only =
|
||||
!resume_after_takeover && skill_check.is_none() && !workspace_file && is_plain_chat(prompt);
|
||||
!resume_after_takeover && !goal_mode && file_skill.is_none() && skill_check.is_none() && !workspace_file && is_plain_chat(prompt);
|
||||
if chat_only {
|
||||
// Memory is recalled separately and injected into the preamble below.
|
||||
// Do not expose even memory tools here: a plain greeting must be one
|
||||
|
|
@ -570,14 +628,16 @@ async fn execute_run(
|
|||
}
|
||||
// Taught skills run long (a 24-page course is 24 clicks); plain chats stay
|
||||
// bounded tighter so a confused model cannot burn budget for as long.
|
||||
let max_turns: u32 = if skill_check.is_some() {
|
||||
80
|
||||
let execution_mode = if goal_mode {
|
||||
ExecutionMode::Goal
|
||||
} else if skill_check.is_some() && file_skill.is_none() {
|
||||
ExecutionMode::Bounded(80)
|
||||
} else if chat_only {
|
||||
4
|
||||
ExecutionMode::Bounded(4)
|
||||
} else {
|
||||
40
|
||||
ExecutionMode::Bounded(40)
|
||||
};
|
||||
let mut nudges: u8 = 0;
|
||||
let mut nudges: u32 = 0;
|
||||
let mut screenshots: u32 = 0;
|
||||
let mut screenshot_bytes: u64 = 0;
|
||||
if resume_after_takeover {
|
||||
|
|
@ -618,6 +678,11 @@ async fn execute_run(
|
|||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
.min(u32::MAX as u64) as u32;
|
||||
// Messages sent to this same thread while a /goal run is working are
|
||||
// steering input. Keep the run alive and deliver each new message once
|
||||
// before the next model turn instead of waiting for a second run to win
|
||||
// the bot lease.
|
||||
let mut steering_seq = checkpoint.get("steeringSeq").and_then(Value::as_i64).map(|seq| seq as i32).unwrap_or(current_seq);
|
||||
let mut used_gui = false;
|
||||
let memory = if ctx.memory_enabled {
|
||||
match state
|
||||
|
|
@ -677,11 +742,20 @@ async fn execute_run(
|
|||
preamble.push_str("\n\n");
|
||||
preamble.push_str(&memory);
|
||||
}
|
||||
if goal_mode {
|
||||
preamble.push_str("\n\n");
|
||||
preamble.push_str(GOAL_INSTRUCTIONS);
|
||||
}
|
||||
if !chat_only {
|
||||
if let Some(index) = crate::skills::skills_preamble(&skills) {
|
||||
preamble.push_str("\n\n");
|
||||
preamble.push_str(&index);
|
||||
}
|
||||
let file_skill_index = crate::file_skills::index(&state.data_dir);
|
||||
if !file_skill_index.is_empty() {
|
||||
preamble.push_str("\n\n");
|
||||
preamble.push_str(&file_skill_index);
|
||||
}
|
||||
if let Ok(accounts) = crate::vault::list_on(state.pool(), actor, bot_id).await {
|
||||
if !accounts.is_empty() {
|
||||
let names = accounts
|
||||
|
|
@ -696,8 +770,37 @@ async fn execute_run(
|
|||
}
|
||||
}
|
||||
|
||||
for _ in turns..max_turns {
|
||||
turns += 1;
|
||||
while execution_mode.allows_turn(turns) {
|
||||
turns = turns.saturating_add(1);
|
||||
if goal_mode {
|
||||
let steering: Vec<(i32, String)> = sqlx::query_as(
|
||||
"SELECT seq, body FROM messages
|
||||
WHERE thread_id=$1 AND role='user' AND seq>$2
|
||||
ORDER BY seq ASC LIMIT 12",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(steering_seq)
|
||||
.fetch_all(state.pool())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some((last_seq, _)) = steering.last() {
|
||||
steering_seq = *last_seq;
|
||||
let guidance = steering
|
||||
.iter()
|
||||
.map(|(_, body)| body.trim())
|
||||
.filter(|body| !body.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if !guidance.is_empty() {
|
||||
let text = format!(
|
||||
"The user added this guidance in the same goal thread. Incorporate it into the current goal and continue verifying the result:\n{}",
|
||||
guidance.join("\n")
|
||||
);
|
||||
if let Message::User { content } = &mut pending {
|
||||
content.push(UserContent::text(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(halt) = renew_or_halt(state, run_id, lease_owner).await? {
|
||||
return finish_halt(
|
||||
state,
|
||||
|
|
@ -761,7 +864,12 @@ async fn execute_run(
|
|||
let parroted = earlier_replies
|
||||
.iter()
|
||||
.any(|earlier| earlier == final_text.trim());
|
||||
let nudge = if parroted {
|
||||
let nudge = if goal_mode {
|
||||
match goal_outcome(&final_text) {
|
||||
GoalOutcome::Continue => Some(GOAL_CONTINUE.to_string()),
|
||||
GoalOutcome::Complete | GoalOutcome::NeedsInput => None,
|
||||
}
|
||||
} else if parroted {
|
||||
Some(
|
||||
"Your reply repeats an earlier message word for word, so it cannot describe the current screen. Below is what the screen shows RIGHT NOW. Act on it with a tool call. Waiting is done by calling wait or by clicking the control (the click waits for it to enable), never by replying. Reply in text only once the task is finished or you are truly blocked (say why).".to_string(),
|
||||
)
|
||||
|
|
@ -773,8 +881,8 @@ async fn execute_run(
|
|||
})
|
||||
};
|
||||
match nudge {
|
||||
Some(text) if nudges < 6 && turns + 2 < max_turns => {
|
||||
nudges += 1;
|
||||
Some(text) if goal_mode || (nudges < 6 && execution_mode.allows_turn(turns.saturating_add(2))) => {
|
||||
nudges = nudges.saturating_add(1);
|
||||
tracing::info!(
|
||||
run_id,
|
||||
turn = turns,
|
||||
|
|
@ -958,7 +1066,7 @@ async fn execute_run(
|
|||
results.extend(screenshot_parts(png));
|
||||
}
|
||||
pending = Message::User { content: results };
|
||||
save_harness_checkpoint(state, run_id, lease_owner, &history, &pending, turns).await?;
|
||||
save_harness_checkpoint(state, run_id, lease_owner, &history, &pending, turns, steering_seq).await?;
|
||||
}
|
||||
|
||||
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1")
|
||||
|
|
@ -980,15 +1088,26 @@ async fn execute_run(
|
|||
)
|
||||
.await;
|
||||
}
|
||||
let needs_input = goal_mode && goal_outcome(&final_text) == GoalOutcome::NeedsInput;
|
||||
if needs_input {
|
||||
let next = Message::User { content: vec![UserContent::text("The goal was paused for required user input. Read the user's new information and continue from completed work.")] };
|
||||
save_harness_checkpoint(state, run_id, lease_owner, &history, &next, turns, steering_seq).await?;
|
||||
}
|
||||
let final_text = final_text
|
||||
.replace("[GOAL_COMPLETE]", "")
|
||||
.replace("[GOAL_BLOCKED]", "")
|
||||
.trim()
|
||||
.to_string();
|
||||
append_bot_message(state, thread_id, run_id, bot_id, &final_text).await?;
|
||||
let completed = sqlx::query(
|
||||
"UPDATE runs
|
||||
SET status='completed', completed_at=now(), updated_at=now(),
|
||||
SET status=$3, completed_at=CASE WHEN $3='completed' THEN now() ELSE NULL END, updated_at=now(),
|
||||
lease_owner=NULL, lease_expires_at=NULL
|
||||
WHERE id=$1 AND lease_owner=$2 AND status='running'",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(lease_owner)
|
||||
.bind(if needs_input { "waiting_input" } else { "completed" })
|
||||
.execute(state.pool())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
|
@ -1001,7 +1120,7 @@ async fn execute_run(
|
|||
state,
|
||||
thread_id,
|
||||
run_id,
|
||||
"run.completed",
|
||||
if needs_input { "run.paused" } else { "run.completed" },
|
||||
turns,
|
||||
screenshots,
|
||||
screenshot_bytes,
|
||||
|
|
@ -1199,6 +1318,7 @@ async fn save_harness_checkpoint(
|
|||
history: &[Message],
|
||||
pending: &Message,
|
||||
turns: u32,
|
||||
steering_seq: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut history = history.to_vec();
|
||||
let mut pending = pending.clone();
|
||||
|
|
@ -1210,7 +1330,7 @@ async fn save_harness_checkpoint(
|
|||
});
|
||||
}
|
||||
}
|
||||
let value = json!({"harnessHistory":history,"harnessPending":pending,"toolsStarted":false,"harnessTurns":turns});
|
||||
let value = json!({"harnessHistory":history,"harnessPending":pending,"toolsStarted":false,"harnessTurns":turns,"steeringSeq":steering_seq});
|
||||
// Large/unsupported checkpoints fail closed: keep the uncertain-effects flag.
|
||||
if value.to_string().len() > 1024 * 1024 {
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -421,7 +421,7 @@ async fn enqueue(state: &AppState, row: &ScheduleRow, from_tick: bool) -> Result
|
|||
.bind(&thread_id)
|
||||
.bind(seq)
|
||||
.bind(&body)
|
||||
.bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"human":describe_cron(&row.cron)}]))
|
||||
.bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"cron":row.cron,"human":describe_cron(&row.cron)}]))
|
||||
.bind(&run_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lazyboy_control::SandboxProvider;
|
||||
|
|
@ -10,6 +11,48 @@ use crate::db::{Actor, Db};
|
|||
use crate::mcp::McpHub;
|
||||
use crate::memory::MemoryService;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CallRegistry {
|
||||
inner: Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl CallRegistry {
|
||||
/// Returns a lease that releases the slot when dropped, so a websocket
|
||||
/// upgrade that never completes cannot leave the bot marked as busy.
|
||||
pub fn try_begin(&self, bot_id: &str, call_id: &str) -> Option<CallLease> {
|
||||
let mut map = self.lock();
|
||||
if map.contains_key(bot_id) {
|
||||
return None;
|
||||
}
|
||||
map.insert(bot_id.to_string(), call_id.to_string());
|
||||
drop(map);
|
||||
Some(CallLease {
|
||||
registry: self.clone(),
|
||||
bot_id: bot_id.to_string(),
|
||||
call_id: call_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
|
||||
self.inner.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CallLease {
|
||||
registry: CallRegistry,
|
||||
bot_id: String,
|
||||
call_id: String,
|
||||
}
|
||||
|
||||
impl Drop for CallLease {
|
||||
fn drop(&mut self) {
|
||||
let mut map = self.registry.lock();
|
||||
if map.get(&self.bot_id).is_some_and(|held| *held == self.call_id) {
|
||||
map.remove(&self.bot_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Db,
|
||||
|
|
@ -18,6 +61,7 @@ pub struct AppState {
|
|||
pub auth: AuthConfig,
|
||||
pub memory: MemoryService,
|
||||
pub mcp: McpHub,
|
||||
pub calls: CallRegistry,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -39,6 +83,7 @@ impl AppState {
|
|||
auth: AuthConfig::from_env(),
|
||||
memory: MemoryService::from_env(),
|
||||
mcp: McpHub::new(),
|
||||
calls: CallRegistry::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1432,5 +1432,6 @@ fn schedule_state(ctx: &ToolCtx) -> crate::state::AppState {
|
|||
auth: crate::auth::AuthConfig::from_env(),
|
||||
memory: ctx.memory.clone(),
|
||||
mcp: ctx.mcp.clone(),
|
||||
calls: crate::state::CallRegistry::default(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use lazyboy_contracts::{VoiceProvider, catalog_voices, computer_voice_tools};
|
||||
use lazyboy_harness::{
|
||||
CredentialChain, ResolveVoiceRequest, resolve_voice, scripted_voice_enabled, voice_catalog,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::{Actor, SpaceRow};
|
||||
use crate::state::AppState;
|
||||
use crate::voice_call;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/voice/settings", get(get_settings).patch(update_settings))
|
||||
.route("/api/sessions/{id}/call", get(voice_call::call_ws))
|
||||
}
|
||||
|
||||
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
|
||||
state
|
||||
.bootstrap()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
pub fn voice_credential_chain(
|
||||
provider: VoiceProvider,
|
||||
voice_api_key: Option<&str>,
|
||||
text_provider: &str,
|
||||
text_api_key: Option<&str>,
|
||||
env_key: Option<&str>,
|
||||
) -> CredentialChain {
|
||||
let nonempty = |value: Option<&str>| {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let space = nonempty(voice_api_key).or_else(|| {
|
||||
if text_provider == provider.as_str() {
|
||||
nonempty(text_api_key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
CredentialChain {
|
||||
bot: None,
|
||||
space,
|
||||
env: nonempty(env_key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voice_instructions(bot_name: &str, bot_instructions: &str) -> String {
|
||||
let mut text = format!(
|
||||
"You are {bot_name}, on a live voice call. Speak shortly and naturally in the user's language. Do not read markdown aloud.\n\
|
||||
You cannot see the screen. Computer progress arrives as text from tools.\n\
|
||||
If the user wants you to operate the computer, answer yes or no immediately, then call start_computer_task. Never pretend you already clicked.\n\
|
||||
If a task is already running and they change their mind, call follow_up_computer_task or stop_computer_task.\n\
|
||||
If a tool returns blocked because they have the screen, tell them to finish on the right-hand desktop."
|
||||
);
|
||||
let extra = bot_instructions.trim();
|
||||
if !extra.is_empty() {
|
||||
text.push_str("\n\nBot instructions:\n");
|
||||
text.push_str(extra);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn listed_providers() -> Vec<VoiceProvider> {
|
||||
let mut providers = VoiceProvider::selectable().to_vec();
|
||||
if scripted_voice_enabled() {
|
||||
providers.push(VoiceProvider::Scripted);
|
||||
}
|
||||
providers
|
||||
}
|
||||
|
||||
fn settings_json(space: &SpaceRow) -> Value {
|
||||
let requested = space
|
||||
.voice_provider
|
||||
.as_deref()
|
||||
.unwrap_or("xai")
|
||||
.parse::<VoiceProvider>()
|
||||
.unwrap_or(VoiceProvider::Xai);
|
||||
let provider = if requested == VoiceProvider::Scripted && !scripted_voice_enabled() {
|
||||
VoiceProvider::Xai
|
||||
} else {
|
||||
requested
|
||||
};
|
||||
let env_key = std::env::var(provider.env_key_name())
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty());
|
||||
let credentials = voice_credential_chain(
|
||||
provider,
|
||||
space.voice_api_key.as_deref(),
|
||||
&space.default_model_provider,
|
||||
space.default_model_api_key.as_deref(),
|
||||
env_key.as_deref(),
|
||||
);
|
||||
let resolved = resolve_voice(ResolveVoiceRequest {
|
||||
provider,
|
||||
model_id: space.voice_model_id.clone(),
|
||||
voice_id: space.voice_id.clone(),
|
||||
credentials,
|
||||
});
|
||||
let (model_id, voice_id, ready, missing) = match &resolved {
|
||||
Ok(value) => (value.model_id.clone(), value.voice_id.clone(), true, None),
|
||||
Err(error) => (
|
||||
space
|
||||
.voice_model_id
|
||||
.clone()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| provider.default_model_id().to_string()),
|
||||
space
|
||||
.voice_id
|
||||
.clone()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| provider.default_voice_id().to_string()),
|
||||
false,
|
||||
Some(error.to_string()),
|
||||
),
|
||||
};
|
||||
let catalog = voice_catalog(provider);
|
||||
json!({
|
||||
"provider": provider.as_str(),
|
||||
"modelId": model_id,
|
||||
"voiceId": voice_id,
|
||||
"enabled": space.voice_enabled,
|
||||
"ready": space.voice_enabled && ready,
|
||||
"missing": missing,
|
||||
"apiKeySet": space.voice_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"envKeySet": env_key.is_some(),
|
||||
"envKeyName": provider.env_key_name(),
|
||||
"reusesTextKey": provider.as_str() == space.default_model_provider
|
||||
&& space.voice_api_key.as_deref().is_none_or(|value| value.is_empty())
|
||||
&& space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"providers": listed_providers().iter().map(|item| json!({
|
||||
"id": item.as_str(),
|
||||
"name": match item {
|
||||
VoiceProvider::Xai => "xAI",
|
||||
VoiceProvider::Openai => "OpenAI",
|
||||
VoiceProvider::Scripted => "Scripted",
|
||||
},
|
||||
"envKeyName": item.env_key_name(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"models": catalog.models.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
|
||||
"voices": catalog.voices.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
|
||||
"tools": computer_voice_tools(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
let actor = actor(&state).await?;
|
||||
let space = state
|
||||
.db
|
||||
.get_space(&actor)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(Json(settings_json(&space)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateVoiceSettings {
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
provider: String,
|
||||
model_id: String,
|
||||
voice_id: String,
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
clear_api_key: bool,
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<UpdateVoiceSettings>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let actor = actor(&state).await?;
|
||||
let provider: VoiceProvider = input
|
||||
.provider
|
||||
.parse()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
if !listed_providers().contains(&provider) {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let model_id = input.model_id.trim();
|
||||
let voice_id = input.voice_id.trim();
|
||||
if model_id.is_empty() || voice_id.is_empty() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
if catalog_voices(provider).iter().all(|(id, _)| *id != voice_id)
|
||||
&& provider != VoiceProvider::Scripted
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let supplied = input
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let api_key = if input.clear_api_key {
|
||||
Some(None)
|
||||
} else {
|
||||
supplied.map(Some)
|
||||
};
|
||||
state
|
||||
.db
|
||||
.update_voice_settings(&actor, input.enabled, provider.as_str(), model_id, voice_id, api_key)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
get_settings(State(state)).await
|
||||
}
|
||||
|
||||
pub fn resolve_space_voice(
|
||||
space: &SpaceRow,
|
||||
) -> Result<(VoiceProvider, lazyboy_harness::ResolvedVoice), String> {
|
||||
if !space.voice_enabled {
|
||||
return Err("語音通話尚未啟用,請到「設定 → 語音」開啟。".into());
|
||||
}
|
||||
let requested = space
|
||||
.voice_provider
|
||||
.as_deref()
|
||||
.unwrap_or("xai")
|
||||
.parse::<VoiceProvider>()
|
||||
.unwrap_or(VoiceProvider::Xai);
|
||||
let provider = if requested == VoiceProvider::Scripted && !scripted_voice_enabled() {
|
||||
VoiceProvider::Xai
|
||||
} else {
|
||||
requested
|
||||
};
|
||||
let env_key = std::env::var(provider.env_key_name())
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty());
|
||||
let credentials = voice_credential_chain(
|
||||
provider,
|
||||
space.voice_api_key.as_deref(),
|
||||
&space.default_model_provider,
|
||||
space.default_model_api_key.as_deref(),
|
||||
env_key.as_deref(),
|
||||
);
|
||||
let resolved = resolve_voice(ResolveVoiceRequest {
|
||||
provider,
|
||||
model_id: space.voice_model_id.clone(),
|
||||
voice_id: space.voice_id.clone(),
|
||||
credentials,
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((provider, resolved))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voice_key_reuses_matching_text_provider_and_does_not_cross_providers() {
|
||||
let reused = voice_credential_chain(
|
||||
VoiceProvider::Xai,
|
||||
None,
|
||||
"xai",
|
||||
Some("text-key"),
|
||||
Some("env-key"),
|
||||
);
|
||||
assert_eq!(reused.resolve(), Some("text-key"));
|
||||
|
||||
let env_only = voice_credential_chain(
|
||||
VoiceProvider::Xai,
|
||||
None,
|
||||
"opencode-go",
|
||||
Some("go-key"),
|
||||
Some("env-xai"),
|
||||
);
|
||||
assert_eq!(env_only.resolve(), Some("env-xai"));
|
||||
|
||||
let dedicated = voice_credential_chain(
|
||||
VoiceProvider::Openai,
|
||||
Some("voice-openai"),
|
||||
"xai",
|
||||
Some("text-xai"),
|
||||
Some("env-openai"),
|
||||
);
|
||||
assert_eq!(dedicated.resolve(), Some("voice-openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_include_bot_name_and_custom_text() {
|
||||
let text = voice_instructions("阿明", "Always prefer the browser.");
|
||||
assert!(text.contains("阿明"));
|
||||
assert!(text.contains("start_computer_task"));
|
||||
assert!(text.contains("Always prefer the browser."));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,657 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use lazyboy_contracts::SessionAttachment;
|
||||
use lazyboy_harness::{VoiceConnectRequest, VoiceEvent, VoiceSocket, create_voice};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::Actor;
|
||||
use crate::state::AppState;
|
||||
use crate::voice::{resolve_space_voice, voice_instructions};
|
||||
|
||||
struct PreparedCall {
|
||||
call_id: String,
|
||||
bot_id: String,
|
||||
bot_name: String,
|
||||
provider: lazyboy_contracts::VoiceProvider,
|
||||
connect: VoiceConnectRequest,
|
||||
}
|
||||
|
||||
pub async fn call_ws(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> axum::response::Response {
|
||||
let actor = match state.bootstrap().await {
|
||||
Ok(actor) => actor,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"message":"internal error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
match prepare_call(&state, &actor, &session_id).await {
|
||||
Ok(prep) => {
|
||||
let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"message":"already on a call"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let provider = create_voice(prep.provider);
|
||||
let socket = match provider.connect(prep.connect.clone()).await {
|
||||
Ok(socket) => socket,
|
||||
Err(error) => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"message": error.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
ws.on_upgrade(move |client| {
|
||||
run_call(state, actor, session_id, prep, client, socket, lease)
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err((status, message)) => (status, Json(json!({"message": message}))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The client asks for a barge-in over the text channel; audio frames stay binary.
|
||||
fn is_interrupt_request(text: &str) -> bool {
|
||||
serde_json::from_str::<Value>(text)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.is_some_and(|kind| kind == "interrupt")
|
||||
}
|
||||
|
||||
async fn prepare_call(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
session_id: &str,
|
||||
) -> Result<PreparedCall, (StatusCode, String)> {
|
||||
let row: Option<(String, Option<String>, String, String)> = sqlx::query_as(
|
||||
"SELECT t.bot_id, t.room_id, b.name, b.instructions
|
||||
FROM threads t JOIN bots b ON b.id=t.bot_id
|
||||
WHERE t.id=$1 AND t.space_id=$2 AND t.user_id=$3 AND t.status='active'",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&actor.space_id)
|
||||
.bind(&actor.user_id)
|
||||
.fetch_optional(state.pool())
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
||||
let Some((bot_id, room_id, bot_name, bot_instructions)) = row else {
|
||||
return Err((StatusCode::NOT_FOUND, "session not found".into()));
|
||||
};
|
||||
if room_id.is_some() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"group calls are not supported".into(),
|
||||
));
|
||||
}
|
||||
let space = state
|
||||
.db
|
||||
.get_space(actor)
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "workspace not found".into()))?;
|
||||
let (provider, resolved) =
|
||||
resolve_space_voice(&space).map_err(|message| (StatusCode::CONFLICT, message))?;
|
||||
let history = recent_text_history(state, session_id).await.unwrap_or_default();
|
||||
let mut connect = VoiceConnectRequest::from_resolved(
|
||||
&resolved,
|
||||
voice_instructions(&bot_name, &bot_instructions),
|
||||
);
|
||||
connect.history = history;
|
||||
Ok(PreparedCall {
|
||||
call_id: Uuid::new_v4().to_string(),
|
||||
bot_id,
|
||||
bot_name,
|
||||
provider,
|
||||
connect,
|
||||
})
|
||||
}
|
||||
|
||||
async fn recent_text_history(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<(String, String)>, sqlx::Error> {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT role, body FROM messages
|
||||
WHERE thread_id=$1 AND role IN ('user','assistant') AND body <> ''
|
||||
ORDER BY seq DESC LIMIT 10",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(state.pool())
|
||||
.await?;
|
||||
Ok(rows.into_iter().rev().collect())
|
||||
}
|
||||
|
||||
async fn run_call(
|
||||
state: AppState,
|
||||
actor: Actor,
|
||||
session_id: String,
|
||||
prep: PreparedCall,
|
||||
client: WebSocket,
|
||||
mut provider: Box<dyn VoiceSocket>,
|
||||
_lease: crate::state::CallLease,
|
||||
) {
|
||||
let call_id = prep.call_id.clone();
|
||||
let bot_id = prep.bot_id.clone();
|
||||
let (client_write, mut client_read) = client.split();
|
||||
let client_write = Arc::new(Mutex::new(client_write));
|
||||
let _ = send_json(
|
||||
&client_write,
|
||||
json!({
|
||||
"type": "ready",
|
||||
"callId": call_id,
|
||||
"botId": bot_id,
|
||||
"botName": prep.bot_name,
|
||||
"provider": prep.provider.as_str(),
|
||||
"voice": prep.connect.voice_id,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut user_partial = String::new();
|
||||
let mut assistant_partial = String::new();
|
||||
let mut last_progress = String::new();
|
||||
let mut response_active = false;
|
||||
let mut last_spoken_at = std::time::Instant::now()
|
||||
.checked_sub(Duration::from_secs(30))
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
// A fresh `sleep` inside `select!` would be cancelled by every microphone
|
||||
// frame (one every few milliseconds), so the poll must own its own timer.
|
||||
let mut watchdog = tokio::time::interval(Duration::from_millis(800));
|
||||
watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
client_msg = client_read.next() => {
|
||||
match client_msg {
|
||||
None | Some(Ok(AxumMessage::Close(_))) => break,
|
||||
Some(Err(_)) => break,
|
||||
Some(Ok(AxumMessage::Ping(payload))) => {
|
||||
let _ = client_write.lock().await.send(AxumMessage::Pong(payload)).await;
|
||||
}
|
||||
Some(Ok(AxumMessage::Pong(_))) => {}
|
||||
Some(Ok(AxumMessage::Binary(bytes))) => {
|
||||
if provider.send(VoiceEvent::AudioPcm(bytes.to_vec())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(AxumMessage::Text(text))) => {
|
||||
if is_interrupt_request(&text) && response_active {
|
||||
response_active = false;
|
||||
if provider.send(VoiceEvent::CancelResponse).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
provider_msg = provider.recv() => {
|
||||
match provider_msg {
|
||||
Ok(None) | Err(_) => break,
|
||||
Ok(Some(event)) => {
|
||||
if handle_provider_event(
|
||||
&state,
|
||||
&actor,
|
||||
&session_id,
|
||||
&prep,
|
||||
&mut provider,
|
||||
&client_write,
|
||||
event,
|
||||
&mut user_partial,
|
||||
&mut assistant_partial,
|
||||
&mut response_active,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = watchdog.tick() => {
|
||||
let _ = watch_computer(
|
||||
&state,
|
||||
&actor,
|
||||
&session_id,
|
||||
&bot_id,
|
||||
prep.provider,
|
||||
&mut provider,
|
||||
&client_write,
|
||||
&mut last_progress,
|
||||
&mut last_spoken_at,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_provider_event(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
session_id: &str,
|
||||
prep: &PreparedCall,
|
||||
provider: &mut Box<dyn VoiceSocket>,
|
||||
client_write: &Arc<Mutex<futures_util::stream::SplitSink<WebSocket, AxumMessage>>>,
|
||||
event: VoiceEvent,
|
||||
user_partial: &mut String,
|
||||
assistant_partial: &mut String,
|
||||
response_active: &mut bool,
|
||||
) -> Result<(), ()> {
|
||||
match event {
|
||||
VoiceEvent::ResponseStarted => *response_active = true,
|
||||
VoiceEvent::ResponseFinished => *response_active = false,
|
||||
VoiceEvent::AudioPcm(bytes) => {
|
||||
// Not every provider announces `response.created`; flowing audio is
|
||||
// proof enough that there is something to cancel on a barge-in.
|
||||
*response_active = true;
|
||||
client_write
|
||||
.lock()
|
||||
.await
|
||||
.send(AxumMessage::Binary(bytes.into()))
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
}
|
||||
VoiceEvent::SpeechStarted => {
|
||||
send_json(client_write, json!({"type":"speech","state":"started"})).await?;
|
||||
}
|
||||
VoiceEvent::SpeechStopped => {
|
||||
send_json(client_write, json!({"type":"speech","state":"stopped"})).await?;
|
||||
}
|
||||
VoiceEvent::InputTranscript { text, final_ } => {
|
||||
if final_ {
|
||||
let body = if text.trim().is_empty() {
|
||||
user_partial.trim().to_string()
|
||||
} else {
|
||||
text.trim().to_string()
|
||||
};
|
||||
user_partial.clear();
|
||||
if !body.is_empty() {
|
||||
let _ = persist_transcript(state, session_id, "user", &body, &prep.call_id).await;
|
||||
send_json(
|
||||
client_write,
|
||||
json!({"type":"transcript","role":"user","text":body,"final":true}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
user_partial.push_str(&text);
|
||||
send_json(
|
||||
client_write,
|
||||
json!({"type":"transcript","role":"user","text":user_partial,"final":false}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
VoiceEvent::OutputTranscript { text, final_ } => {
|
||||
if final_ {
|
||||
*response_active = false;
|
||||
let body = if text.trim().is_empty() {
|
||||
assistant_partial.trim().to_string()
|
||||
} else {
|
||||
text.trim().to_string()
|
||||
};
|
||||
assistant_partial.clear();
|
||||
if !body.is_empty() {
|
||||
let _ =
|
||||
persist_transcript(state, session_id, "assistant", &body, &prep.call_id)
|
||||
.await;
|
||||
send_json(
|
||||
client_write,
|
||||
json!({"type":"transcript","role":"assistant","text":body,"final":true}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
assistant_partial.push_str(&text);
|
||||
send_json(
|
||||
client_write,
|
||||
json!({"type":"transcript","role":"assistant","text":assistant_partial,"final":false}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
VoiceEvent::FunctionCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
let output = dispatch_voice_tool(state, actor, &prep.bot_id, session_id, &prep.call_id, &name, &arguments)
|
||||
.await;
|
||||
let _ = provider
|
||||
.send(VoiceEvent::FunctionCallOutput {
|
||||
call_id,
|
||||
output: output.to_string(),
|
||||
})
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
let _ = provider.send(VoiceEvent::ResponseCreate).await;
|
||||
if let Some(status) = output.get("status").and_then(Value::as_str) {
|
||||
send_json(
|
||||
client_write,
|
||||
json!({
|
||||
"type":"computer",
|
||||
"status": status,
|
||||
"step": output.get("step"),
|
||||
"takeover": output.get("takeover").and_then(Value::as_bool).unwrap_or(false)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
VoiceEvent::Error { message } => {
|
||||
send_json(client_write, json!({"type":"error","message":message})).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch_voice_tool(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
bot_id: &str,
|
||||
session_id: &str,
|
||||
call_id: &str,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
) -> Value {
|
||||
match name {
|
||||
"computer_status" => computer_tool_status(state, actor, bot_id, session_id).await,
|
||||
"stop_computer_task" => match crate::sessions::cancel_session_runs(state, session_id).await
|
||||
{
|
||||
Ok(ids) => json!({"status":"cancelled","runIds": ids}),
|
||||
Err(error) => json!({"status":"error","reason": error}),
|
||||
},
|
||||
"start_computer_task" | "follow_up_computer_task" => {
|
||||
let prompt = serde_json::from_str::<Value>(arguments)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let prompt = prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return json!({"status":"error","reason":"missing prompt"});
|
||||
}
|
||||
start_or_follow(state, actor, bot_id, session_id, call_id, &prompt).await
|
||||
}
|
||||
other => json!({"status":"error","reason": format!("unknown tool {other}")}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_or_follow(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
bot_id: &str,
|
||||
session_id: &str,
|
||||
call_id: &str,
|
||||
prompt: &str,
|
||||
) -> Value {
|
||||
if crate::skills::recording_skill(state.pool(), bot_id)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
return json!({"status":"blocked","reason":"teaching"});
|
||||
}
|
||||
let status = computer_tool_status(state, actor, bot_id, session_id).await;
|
||||
if status
|
||||
.get("takeover")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| status.get("status").and_then(Value::as_str) == Some("takeover")
|
||||
{
|
||||
return json!({"status":"blocked","reason":"takeover","takeover":true});
|
||||
}
|
||||
if status.get("status").and_then(Value::as_str) == Some("user_control") {
|
||||
return json!({"status":"blocked","reason":"user_control"});
|
||||
}
|
||||
let blocks = vec![json!({"kind":"voice","callId": call_id})];
|
||||
match crate::runs::send(
|
||||
state,
|
||||
actor,
|
||||
bot_id,
|
||||
session_id,
|
||||
prompt,
|
||||
Some(&Uuid::new_v4().to_string()),
|
||||
&blocks,
|
||||
&[] as &[SessionAttachment],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => json!({
|
||||
"status": if result.get("queuedBehindActive").and_then(Value::as_bool).unwrap_or(false) {
|
||||
"followed_up"
|
||||
} else {
|
||||
"queued"
|
||||
},
|
||||
"runId": result.get("runId"),
|
||||
}),
|
||||
Err(error) => json!({"status":"error","reason": error}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn computer_tool_status(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
bot_id: &str,
|
||||
session_id: &str,
|
||||
) -> Value {
|
||||
let holder: Option<String> = sqlx::query_scalar(
|
||||
"SELECT c.control_holder FROM computers c
|
||||
JOIN bots b ON b.computer_id=c.id
|
||||
WHERE b.id=$1 AND b.space_id=$2 AND b.user_id=$3",
|
||||
)
|
||||
.bind(bot_id)
|
||||
.bind(&actor.space_id)
|
||||
.bind(&actor.user_id)
|
||||
.fetch_optional(state.pool())
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if holder.as_deref() == Some("user") {
|
||||
return json!({"status":"user_control","takeover":false});
|
||||
}
|
||||
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, status, checkpoint->>'step'
|
||||
FROM runs
|
||||
WHERE bot_id=$1 AND thread_id=$2
|
||||
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
||||
ORDER BY CASE status
|
||||
WHEN 'running' THEN 0 WHEN 'leased' THEN 1
|
||||
WHEN 'waiting_takeover' THEN 2 WHEN 'waiting_input' THEN 3
|
||||
ELSE 4 END,
|
||||
created_at ASC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(bot_id)
|
||||
.bind(session_id)
|
||||
.fetch_optional(state.pool())
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match row {
|
||||
Some((run_id, status, step)) if status == "waiting_takeover" => json!({
|
||||
"status":"takeover",
|
||||
"runId": run_id,
|
||||
"step": step,
|
||||
"takeover": true
|
||||
}),
|
||||
Some((run_id, status, step)) => json!({
|
||||
"status": status,
|
||||
"runId": run_id,
|
||||
"step": step,
|
||||
"takeover": false
|
||||
}),
|
||||
None => json!({"status":"idle","takeover":false}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch_computer(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
session_id: &str,
|
||||
bot_id: &str,
|
||||
voice_provider: lazyboy_contracts::VoiceProvider,
|
||||
provider: &mut Box<dyn VoiceSocket>,
|
||||
client_write: &Arc<Mutex<futures_util::stream::SplitSink<WebSocket, AxumMessage>>>,
|
||||
last_progress: &mut String,
|
||||
last_spoken_at: &mut std::time::Instant,
|
||||
) -> Result<(), ()> {
|
||||
let snapshot = computer_tool_status(state, actor, bot_id, session_id).await;
|
||||
let key = snapshot.to_string();
|
||||
if key == *last_progress {
|
||||
return Ok(());
|
||||
}
|
||||
let previous = last_progress.clone();
|
||||
*last_progress = key;
|
||||
send_json(
|
||||
client_write,
|
||||
json!({
|
||||
"type":"computer",
|
||||
"status": snapshot.get("status"),
|
||||
"step": snapshot.get("step"),
|
||||
"takeover": snapshot.get("takeover")
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
if let Some(line) = speakable_progress(&previous, &snapshot) {
|
||||
let urgent = snapshot.get("status").and_then(Value::as_str)
|
||||
== Some("takeover")
|
||||
|| snapshot.get("status").and_then(Value::as_str) == Some("failed");
|
||||
if urgent || last_spoken_at.elapsed() > Duration::from_secs(6) {
|
||||
let _ = provider.send(VoiceEvent::SpeakNow { text: line }).await;
|
||||
if voice_provider == lazyboy_contracts::VoiceProvider::Openai {
|
||||
let _ = provider.send(VoiceEvent::ResponseCreate).await;
|
||||
}
|
||||
*last_spoken_at = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn speakable_progress(previous: &str, snapshot: &Value) -> Option<String> {
|
||||
let status = snapshot.get("status")?.as_str()?;
|
||||
if previous.contains(&format!("\"status\":\"{status}\"")) {
|
||||
return None;
|
||||
}
|
||||
match status {
|
||||
"takeover" => Some("需要你接手畫面登入或驗證。".into()),
|
||||
"user_control" => Some("畫面現在在你手上,完成後再叫我繼續。".into()),
|
||||
"failed" => Some("這次電腦操作沒做成。".into()),
|
||||
"idle" if previous.contains("running") || previous.contains("queued") => {
|
||||
Some("電腦上的工作做完了。".into())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_transcript(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
body: &str,
|
||||
call_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut tx = state
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let seq: i32 = sqlx::query_scalar(
|
||||
"UPDATE threads SET next_message_seq=next_message_seq+1, updated_at=now()
|
||||
WHERE id=$1 RETURNING next_message_seq-1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id,thread_id,seq,role,body,blocks)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)",
|
||||
)
|
||||
.bind(&message_id)
|
||||
.bind(session_id)
|
||||
.bind(seq)
|
||||
.bind(role)
|
||||
.bind(body)
|
||||
.bind(json!([{"kind":"voice","callId": call_id}]))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
tx.commit().await.map_err(|error| error.to_string())?;
|
||||
let _ = crate::sessions::append_event(
|
||||
state,
|
||||
session_id,
|
||||
"message.created",
|
||||
json!({"id":message_id,"seq":seq,"role":role,"body":body,"voice":true}),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_json(
|
||||
client_write: &Arc<Mutex<futures_util::stream::SplitSink<WebSocket, AxumMessage>>>,
|
||||
value: Value,
|
||||
) -> Result<(), ()> {
|
||||
client_write
|
||||
.lock()
|
||||
.await
|
||||
.send(AxumMessage::Text(value.to_string().into()))
|
||||
.await
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_interrupt_request, speakable_progress};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn only_a_typed_interrupt_frame_cancels_the_reply() {
|
||||
assert!(is_interrupt_request(r#"{"type":"interrupt"}"#));
|
||||
assert!(!is_interrupt_request(r#"{"type":"hangup"}"#));
|
||||
assert!(!is_interrupt_request("interrupt"));
|
||||
assert!(!is_interrupt_request(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeover_and_idle_after_work_are_spoken_once() {
|
||||
let takeover = json!({"status":"takeover","takeover":true});
|
||||
assert_eq!(
|
||||
speakable_progress("{\"status\":\"running\"}", &takeover).as_deref(),
|
||||
Some("需要你接手畫面登入或驗證。")
|
||||
);
|
||||
assert!(speakable_progress(&takeover.to_string(), &takeover).is_none());
|
||||
let idle = json!({"status":"idle"});
|
||||
assert!(speakable_progress("{\"status\":\"running\"}", &idle).unwrap().contains("做完"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use axum::Router;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
/// Vite puts built files in `dist/` and source assets in `public/`.
|
||||
/// `LAZYBOY_WEB_DIR` is sometimes the source tree (`apps/web`) and sometimes
|
||||
/// the build output (`apps/web/dist` or Docker `/web`).
|
||||
pub fn resolve_web_roots(configured: &str) -> (PathBuf, Option<PathBuf>) {
|
||||
let configured = PathBuf::from(configured);
|
||||
let dist = if configured.file_name().is_some_and(|name| name == "dist") {
|
||||
configured.clone()
|
||||
} else {
|
||||
configured.join("dist")
|
||||
};
|
||||
let primary = if dist.join("index.html").is_file() {
|
||||
dist
|
||||
} else {
|
||||
configured.clone()
|
||||
};
|
||||
let public = [
|
||||
configured.join("public"),
|
||||
configured
|
||||
.parent()
|
||||
.map(|parent| parent.join("public"))
|
||||
.unwrap_or_default(),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|path| path.is_dir());
|
||||
(primary, public)
|
||||
}
|
||||
|
||||
pub fn static_router(configured: &str) -> Router {
|
||||
let (primary, public) = resolve_web_roots(configured);
|
||||
let fallback = public.clone().unwrap_or_else(|| primary.clone());
|
||||
let files = ServeDir::new(&primary).fallback(ServeDir::new(fallback));
|
||||
let mut router = Router::new().fallback_service(files);
|
||||
if let Some(icon) = first_existing(&[
|
||||
primary.join("favicon.ico"),
|
||||
primary.join("favicon.png"),
|
||||
public
|
||||
.as_ref()
|
||||
.map(|path| path.join("favicon.ico"))
|
||||
.unwrap_or_default(),
|
||||
public
|
||||
.as_ref()
|
||||
.map(|path| path.join("favicon.png"))
|
||||
.unwrap_or_default(),
|
||||
]) {
|
||||
router = router.route_service("/favicon.ico", ServeFile::new(icon));
|
||||
}
|
||||
router
|
||||
}
|
||||
|
||||
fn first_existing(paths: &[PathBuf]) -> Option<PathBuf> {
|
||||
paths.iter().find(|path| path.is_file()).cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lazyboy-web-static-{}-{name}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tree_falls_back_to_public_assets() {
|
||||
let root = scratch("source");
|
||||
fs::write(root.join("index.html"), "<!doctype html>").unwrap();
|
||||
fs::create_dir_all(root.join("public")).unwrap();
|
||||
fs::write(root.join("public/favicon.svg"), "<svg></svg>").unwrap();
|
||||
let (primary, public) = resolve_web_roots(root.to_str().unwrap());
|
||||
assert_eq!(primary, root);
|
||||
assert_eq!(public.as_deref(), Some(root.join("public").as_path()));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_vite_dist_when_present() {
|
||||
let root = scratch("built");
|
||||
fs::write(root.join("index.html"), "source").unwrap();
|
||||
fs::create_dir_all(root.join("dist")).unwrap();
|
||||
fs::write(root.join("dist/index.html"), "built").unwrap();
|
||||
fs::create_dir_all(root.join("public")).unwrap();
|
||||
let (primary, public) = resolve_web_roots(root.to_str().unwrap());
|
||||
assert_eq!(primary, root.join("dist"));
|
||||
assert_eq!(public.as_deref(), Some(root.join("public").as_path()));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dist_dir_uses_sibling_public() {
|
||||
let root = scratch("dist-only");
|
||||
let dist = root.join("dist");
|
||||
fs::create_dir_all(&dist).unwrap();
|
||||
fs::write(dist.join("index.html"), "built").unwrap();
|
||||
fs::create_dir_all(root.join("public")).unwrap();
|
||||
let (primary, public) = resolve_web_roots(dist.to_str().unwrap());
|
||||
assert_eq!(primary, dist);
|
||||
assert_eq!(public.as_deref(), Some(root.join("public").as_path()));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ mod model;
|
|||
mod room;
|
||||
mod run;
|
||||
mod session;
|
||||
mod voice;
|
||||
|
||||
pub use action::*;
|
||||
pub use bot::*;
|
||||
|
|
@ -15,5 +16,6 @@ pub use model::*;
|
|||
pub use room::*;
|
||||
pub use run::*;
|
||||
pub use session::*;
|
||||
pub use voice::*;
|
||||
|
||||
pub type Id = String;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum VoiceProvider {
|
||||
Xai,
|
||||
Openai,
|
||||
Scripted,
|
||||
}
|
||||
|
||||
impl VoiceProvider {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xai => "xai",
|
||||
Self::Openai => "openai",
|
||||
Self::Scripted => "scripted",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn env_key_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xai => "XAI_API_KEY",
|
||||
Self::Openai => "OPENAI_API_KEY",
|
||||
Self::Scripted => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_api_key(self) -> bool {
|
||||
!matches!(self, Self::Scripted)
|
||||
}
|
||||
|
||||
pub fn default_model_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xai => DEFAULT_XAI_VOICE_MODEL,
|
||||
Self::Openai => DEFAULT_OPENAI_VOICE_MODEL,
|
||||
Self::Scripted => "scripted-voice",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_voice_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xai => "eve",
|
||||
Self::Openai => "marin",
|
||||
Self::Scripted => "scripted",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn realtime_url(self, model_id: &str) -> String {
|
||||
match self {
|
||||
Self::Xai => format!("wss://api.x.ai/v1/realtime?model={model_id}"),
|
||||
Self::Openai => format!("wss://api.openai.com/v1/realtime?model={model_id}"),
|
||||
Self::Scripted => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selectable() -> &'static [Self] {
|
||||
&[Self::Xai, Self::Openai]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for VoiceProvider {
|
||||
type Err = UnknownVoiceProvider;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"xai" => Ok(Self::Xai),
|
||||
"openai" => Ok(Self::Openai),
|
||||
"scripted" => Ok(Self::Scripted),
|
||||
other => Err(UnknownVoiceProvider(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
#[error("unknown voice provider: {0}")]
|
||||
pub struct UnknownVoiceProvider(pub String);
|
||||
|
||||
pub const DEFAULT_XAI_VOICE_MODEL: &str = "grok-voice-latest";
|
||||
pub const DEFAULT_OPENAI_VOICE_MODEL: &str = "gpt-realtime";
|
||||
pub const VOICE_SAMPLE_RATE: u32 = 24_000;
|
||||
|
||||
pub fn catalog_voice_models(provider: VoiceProvider) -> &'static [(&'static str, &'static str)] {
|
||||
match provider {
|
||||
VoiceProvider::Xai => &[
|
||||
("grok-voice-latest", "Grok Voice Latest"),
|
||||
("grok-voice-think-fast-2.0", "Grok Voice Think Fast 2.0"),
|
||||
("grok-voice-think-fast-1.0", "Grok Voice Think Fast 1.0"),
|
||||
],
|
||||
VoiceProvider::Openai => &[("gpt-realtime", "GPT Realtime")],
|
||||
VoiceProvider::Scripted => &[("scripted-voice", "Scripted")],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn catalog_voices(provider: VoiceProvider) -> &'static [(&'static str, &'static str)] {
|
||||
match provider {
|
||||
VoiceProvider::Xai => &[
|
||||
("eve", "Eve"),
|
||||
("ara", "Ara"),
|
||||
("leo", "Leo"),
|
||||
("rex", "Rex"),
|
||||
("sal", "Sal"),
|
||||
],
|
||||
VoiceProvider::Openai => &[
|
||||
("marin", "Marin"),
|
||||
("alloy", "Alloy"),
|
||||
("verse", "Verse"),
|
||||
("cedar", "Cedar"),
|
||||
],
|
||||
VoiceProvider::Scripted => &[("scripted", "Scripted")],
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom functions the voice model may call. LazyBoy executes them; they return immediately.
|
||||
pub fn computer_voice_tools() -> Vec<Value> {
|
||||
vec![
|
||||
function_tool(
|
||||
"start_computer_task",
|
||||
"Start a computer task on the user's Linux desktop. Returns immediately — speak to the user first, do not wait for the work to finish. Use when they want you to operate the computer (open a site, click, download, run a command).",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "What to do on the computer, in the user's language."
|
||||
}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}),
|
||||
),
|
||||
function_tool(
|
||||
"follow_up_computer_task",
|
||||
"Add or change instructions for the computer task that is already running. Use when the user interrupts with a correction.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The new or extra instruction."
|
||||
}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}),
|
||||
),
|
||||
function_tool(
|
||||
"stop_computer_task",
|
||||
"Cancel the computer task that is running.",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
),
|
||||
function_tool(
|
||||
"computer_status",
|
||||
"Check whether a computer task is running, queued, needs the user to take over the screen, or is idle.",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn function_tool(name: &str, description: &str, parameters: Value) -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": parameters
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voice_provider_roundtrip() {
|
||||
assert_eq!("xai".parse::<VoiceProvider>().unwrap(), VoiceProvider::Xai);
|
||||
assert_eq!(
|
||||
"openai".parse::<VoiceProvider>().unwrap(),
|
||||
VoiceProvider::Openai
|
||||
);
|
||||
assert!("anthropic".parse::<VoiceProvider>().is_err());
|
||||
assert_eq!(VoiceProvider::Xai.default_voice_id(), "eve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_tools_are_named() {
|
||||
let tools = computer_voice_tools();
|
||||
let names: Vec<_> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name")?.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"start_computer_task",
|
||||
"follow_up_computer_task",
|
||||
"stop_computer_task",
|
||||
"computer_status"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,16 @@ publish.workspace = true
|
|||
[dependencies]
|
||||
lazyboy-contracts.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
rig-core.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
futures-util.workspace = true
|
||||
base64.workspace = true
|
||||
http = "1"
|
||||
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-native-certs = "0.8"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
//! Execution policy belongs to the harness, independently of task playbooks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExecutionMode {
|
||||
Bounded(u32),
|
||||
Goal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GoalOutcome {
|
||||
Continue,
|
||||
Complete,
|
||||
NeedsInput,
|
||||
}
|
||||
|
||||
pub fn goal_request(prompt: &str) -> Option<&str> {
|
||||
let rest = prompt.trim().strip_prefix("/goal")?;
|
||||
(rest.is_empty() || rest.starts_with(char::is_whitespace)).then(|| rest.trim())
|
||||
}
|
||||
|
||||
impl ExecutionMode {
|
||||
pub fn allows_turn(self, turns: u32) -> bool {
|
||||
match self {
|
||||
Self::Goal => true,
|
||||
Self::Bounded(limit) => turns < limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a standalone terminal marker is an outcome, not a quoted mention.
|
||||
pub fn goal_outcome(reply: &str) -> GoalOutcome {
|
||||
match reply.trim().lines().last().map(str::trim) {
|
||||
Some("[GOAL_COMPLETE]") => GoalOutcome::Complete,
|
||||
Some("[GOAL_BLOCKED]") => GoalOutcome::NeedsInput,
|
||||
_ => GoalOutcome::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
pub const GOAL_INSTRUCTIONS: &str = "Persistent goal execution: plan the requested work, execute it, and verify each requested outcome. Intermediate progress replies do not finish the run. Preserve completed work and incorporate user steering. End your final reply with a standalone [GOAL_COMPLETE] line only when all outcomes are verified; explain the verification. When required information or human action is missing, explain exactly what is needed and end with a standalone [GOAL_BLOCKED] line. For login, CAPTCHA or 2FA use request_takeover. Never claim completion merely because you planned the work.";
|
||||
pub const GOAL_CONTINUE: &str = "The goal remains active. Continue the plan with tools and verify the outcome. Finish only with a standalone [GOAL_COMPLETE] line after verification, or [GOAL_BLOCKED] when required human input is missing.";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn goals_are_unbounded_while_normal_runs_remain_bounded() {
|
||||
assert!(ExecutionMode::Goal.allows_turn(40));
|
||||
assert!(ExecutionMode::Goal.allows_turn(u32::MAX));
|
||||
assert!(!ExecutionMode::Bounded(40).allows_turn(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_command_requires_a_token_boundary() {
|
||||
assert_eq!(goal_request(" /goal\nfinish this"), Some("finish this"));
|
||||
assert_eq!(goal_request("/goalkeeper"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_and_quoted_markers_do_not_complete_a_goal() {
|
||||
assert_eq!(goal_outcome("Next I will use [GOAL_COMPLETE]."), GoalOutcome::Continue);
|
||||
assert_eq!(goal_outcome("Verified output.\n[GOAL_COMPLETE]"), GoalOutcome::Complete);
|
||||
assert_eq!(goal_outcome("Please supply the date.\n[GOAL_BLOCKED]"), GoalOutcome::NeedsInput);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
mod resolve;
|
||||
pub mod execution;
|
||||
mod voice;
|
||||
|
||||
pub use resolve::*;
|
||||
pub use voice::*;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,741 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use lazyboy_contracts::{
|
||||
VoiceProvider, catalog_voice_models, catalog_voices, computer_voice_tools,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
|
||||
use crate::{CredentialChain, ModelError};
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum VoiceError {
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("missing credential for {provider} ({env_key})")]
|
||||
MissingCredential { provider: String, env_key: String },
|
||||
#[error("unknown voice provider: {0}")]
|
||||
UnknownProvider(String),
|
||||
}
|
||||
|
||||
impl From<ModelError> for VoiceError {
|
||||
fn from(error: ModelError) -> Self {
|
||||
match error {
|
||||
ModelError::MissingCredential { provider, env_key } => {
|
||||
Self::MissingCredential { provider, env_key }
|
||||
}
|
||||
other => Self::Message(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VoiceCatalogEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VoiceCatalog {
|
||||
pub provider: VoiceProvider,
|
||||
pub models: Vec<VoiceCatalogEntry>,
|
||||
pub voices: Vec<VoiceCatalogEntry>,
|
||||
}
|
||||
|
||||
pub fn voice_catalog(provider: VoiceProvider) -> VoiceCatalog {
|
||||
VoiceCatalog {
|
||||
provider,
|
||||
models: catalog_voice_models(provider)
|
||||
.iter()
|
||||
.map(|(id, name)| VoiceCatalogEntry {
|
||||
id: (*id).into(),
|
||||
name: (*name).into(),
|
||||
})
|
||||
.collect(),
|
||||
voices: catalog_voices(provider)
|
||||
.iter()
|
||||
.map(|(id, name)| VoiceCatalogEntry {
|
||||
id: (*id).into(),
|
||||
name: (*name).into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveVoiceRequest {
|
||||
pub provider: VoiceProvider,
|
||||
pub model_id: Option<String>,
|
||||
pub voice_id: Option<String>,
|
||||
pub credentials: CredentialChain,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedVoice {
|
||||
pub provider: VoiceProvider,
|
||||
pub model_id: String,
|
||||
pub voice_id: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
pub fn resolve_voice(request: ResolveVoiceRequest) -> Result<ResolvedVoice, VoiceError> {
|
||||
let api_key = match request.credentials.resolve() {
|
||||
Some(key) => key.to_string(),
|
||||
None if request.provider.requires_api_key() => {
|
||||
return Err(VoiceError::MissingCredential {
|
||||
provider: request.provider.as_str().to_string(),
|
||||
env_key: request.provider.env_key_name().to_string(),
|
||||
});
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let model_id = request
|
||||
.model_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| request.provider.default_model_id().to_string());
|
||||
let voice_id = request
|
||||
.voice_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| request.provider.default_voice_id().to_string());
|
||||
Ok(ResolvedVoice {
|
||||
provider: request.provider,
|
||||
model_id,
|
||||
voice_id,
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VoiceEvent {
|
||||
AudioPcm(Vec<u8>),
|
||||
SpeechStarted,
|
||||
SpeechStopped,
|
||||
InputTranscript { text: String, final_: bool },
|
||||
OutputTranscript { text: String, final_: bool },
|
||||
FunctionCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
FunctionCallOutput { call_id: String, output: String },
|
||||
SpeakNow { text: String },
|
||||
InjectContext { text: String },
|
||||
ResponseCreate,
|
||||
ResponseStarted,
|
||||
ResponseFinished,
|
||||
CancelResponse,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceConnectRequest {
|
||||
pub api_key: String,
|
||||
pub model_id: String,
|
||||
pub voice_id: String,
|
||||
pub instructions: String,
|
||||
pub tools: Vec<Value>,
|
||||
pub history: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl VoiceConnectRequest {
|
||||
pub fn from_resolved(resolved: &ResolvedVoice, instructions: String) -> Self {
|
||||
Self {
|
||||
api_key: resolved.api_key.clone(),
|
||||
model_id: resolved.model_id.clone(),
|
||||
voice_id: resolved.voice_id.clone(),
|
||||
instructions,
|
||||
tools: computer_voice_tools(),
|
||||
history: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait VoiceRealtime: Send + Sync {
|
||||
fn provider(&self) -> VoiceProvider;
|
||||
async fn connect(
|
||||
&self,
|
||||
request: VoiceConnectRequest,
|
||||
) -> Result<Box<dyn VoiceSocket>, VoiceError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait VoiceSocket: Send {
|
||||
async fn send(&self, event: VoiceEvent) -> Result<(), VoiceError>;
|
||||
async fn recv(&mut self) -> Result<Option<VoiceEvent>, VoiceError>;
|
||||
}
|
||||
|
||||
pub fn create_voice(provider: VoiceProvider) -> Box<dyn VoiceRealtime> {
|
||||
match provider {
|
||||
VoiceProvider::Scripted => Box::new(ScriptedVoice),
|
||||
other => Box::new(HostedVoice { provider: other }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scripted_voice_enabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("LAZYBOY_VOICE_SCRIPTED").as_deref(),
|
||||
Ok("1" | "true" | "yes")
|
||||
)
|
||||
}
|
||||
|
||||
fn native_roots() -> Result<rustls::RootCertStore, VoiceError> {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
let certs = rustls_native_certs::load_native_certs();
|
||||
roots.add_parsable_certificates(certs.certs);
|
||||
if roots.is_empty() {
|
||||
return Err(VoiceError::Message("No trusted TLS certificates available".into()));
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
struct HostedVoice {
|
||||
provider: VoiceProvider,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VoiceRealtime for HostedVoice {
|
||||
fn provider(&self) -> VoiceProvider {
|
||||
self.provider
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
&self,
|
||||
request: VoiceConnectRequest,
|
||||
) -> Result<Box<dyn VoiceSocket>, VoiceError> {
|
||||
let url = self.provider.realtime_url(&request.model_id);
|
||||
let mut http_request = url
|
||||
.as_str()
|
||||
.into_client_request()
|
||||
.map_err(|error| VoiceError::Message(error.to_string()))?;
|
||||
let header = format!("Bearer {}", request.api_key);
|
||||
http_request.headers_mut().insert(
|
||||
"Authorization",
|
||||
http::HeaderValue::from_str(&header)
|
||||
.map_err(|error| VoiceError::Message(error.to_string()))?,
|
||||
);
|
||||
// Choose explicitly: the dependency graph enables both ring and aws-lc-rs.
|
||||
// Rustls's automatic provider selection panics in that configuration.
|
||||
let tls = rustls::ClientConfig::builder_with_provider(
|
||||
Arc::new(rustls::crypto::ring::default_provider()),
|
||||
)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|error| VoiceError::Message(error.to_string()))?
|
||||
.with_root_certificates(native_roots()?)
|
||||
.with_no_client_auth();
|
||||
let (stream, _) = tokio_tungstenite::connect_async_tls_with_config(
|
||||
http_request, None, false, Some(tokio_tungstenite::Connector::Rustls(Arc::new(tls))),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| VoiceError::Message(error.to_string()))?;
|
||||
let (write, read) = stream.split();
|
||||
let socket = HostedSocket {
|
||||
provider: self.provider,
|
||||
write: Mutex::new(write),
|
||||
read,
|
||||
};
|
||||
socket
|
||||
.send_raw(Message::Text(session_update_json(self.provider, &request).into()))
|
||||
.await?;
|
||||
for (role, text) in &request.history {
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let item = json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": if *role == "assistant" { "output_text" } else { "input_text" },
|
||||
"text": text
|
||||
}]
|
||||
}
|
||||
});
|
||||
socket
|
||||
.send_raw(Message::Text(item.to_string().into()))
|
||||
.await?;
|
||||
}
|
||||
Ok(Box::new(socket))
|
||||
}
|
||||
}
|
||||
|
||||
type HostedWrite =
|
||||
futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, Message>;
|
||||
type HostedRead =
|
||||
futures_util::stream::SplitStream<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>;
|
||||
|
||||
struct HostedSocket {
|
||||
provider: VoiceProvider,
|
||||
write: Mutex<HostedWrite>,
|
||||
read: HostedRead,
|
||||
}
|
||||
|
||||
impl HostedSocket {
|
||||
async fn send_raw(&self, message: Message) -> Result<(), VoiceError> {
|
||||
self.write
|
||||
.lock()
|
||||
.await
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|error| VoiceError::Message(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VoiceSocket for HostedSocket {
|
||||
async fn send(&self, event: VoiceEvent) -> Result<(), VoiceError> {
|
||||
match encode_provider_event(self.provider, &event) {
|
||||
Some(message) => self.send_raw(message).await,
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Option<VoiceEvent>, VoiceError> {
|
||||
loop {
|
||||
match self.read.next().await {
|
||||
None => return Ok(None),
|
||||
Some(Err(error)) => return Err(VoiceError::Message(error.to_string())),
|
||||
Some(Ok(Message::Close(_))) => return Ok(None),
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
let _ = self.send_raw(Message::Pong(payload)).await;
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {}
|
||||
Some(Ok(Message::Frame(_))) => {}
|
||||
Some(Ok(Message::Binary(bytes))) => {
|
||||
return Ok(Some(VoiceEvent::AudioPcm(bytes.to_vec())));
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Some(event) = parse_provider_event(&text) {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn session_update_json(provider: VoiceProvider, request: &VoiceConnectRequest) -> String {
|
||||
let mut session = json!({
|
||||
"voice": request.voice_id,
|
||||
"instructions": request.instructions,
|
||||
"turn_detection": { "type": "server_vad" },
|
||||
"tools": request.tools,
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": { "type": "audio/pcm", "rate": 24000 },
|
||||
"transport": "binary"
|
||||
},
|
||||
"output": {
|
||||
"format": { "type": "audio/pcm", "rate": 24000 },
|
||||
"transport": "binary"
|
||||
}
|
||||
}
|
||||
});
|
||||
if provider == VoiceProvider::Openai {
|
||||
session.as_object_mut().unwrap().remove("voice");
|
||||
session.as_object_mut().unwrap().remove("turn_detection");
|
||||
session["type"] = json!("realtime");
|
||||
session["audio"] = json!({
|
||||
"input": {
|
||||
"format": { "type": "audio/pcm", "rate": 24000 },
|
||||
"turn_detection": { "type": "server_vad" }
|
||||
},
|
||||
"output": {
|
||||
"format": { "type": "audio/pcm", "rate": 24000 },
|
||||
"voice": request.voice_id
|
||||
}
|
||||
});
|
||||
}
|
||||
json!({ "type": "session.update", "session": session }).to_string()
|
||||
}
|
||||
|
||||
pub fn encode_provider_event(provider: VoiceProvider, event: &VoiceEvent) -> Option<Message> {
|
||||
match event {
|
||||
VoiceEvent::AudioPcm(bytes) if provider == VoiceProvider::Openai => Some(Message::Text(
|
||||
json!({ "type": "input_audio_buffer.append", "audio": base64::engine::general_purpose::STANDARD.encode(bytes) }).to_string().into(),
|
||||
)),
|
||||
VoiceEvent::AudioPcm(bytes) => Some(Message::Binary(bytes.clone().into())),
|
||||
VoiceEvent::FunctionCallOutput { call_id, output } => Some(Message::Text(
|
||||
json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)),
|
||||
VoiceEvent::ResponseCreate => Some(Message::Text(
|
||||
json!({ "type": "response.create" }).to_string().into(),
|
||||
)),
|
||||
VoiceEvent::CancelResponse => Some(Message::Text(
|
||||
json!({ "type": "response.cancel" }).to_string().into(),
|
||||
)),
|
||||
VoiceEvent::InjectContext { text } => Some(Message::Text(
|
||||
json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": text }]
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)),
|
||||
VoiceEvent::SpeakNow { text } if provider == VoiceProvider::Xai => Some(Message::Text(
|
||||
json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "force_message",
|
||||
"role": "assistant",
|
||||
"interruptible": true,
|
||||
"content": [{ "type": "output_text", "text": text }]
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)),
|
||||
VoiceEvent::SpeakNow { text } => Some(Message::Text(
|
||||
json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": text }]
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_provider_event(text: &str) -> Option<VoiceEvent> {
|
||||
let event: Value = serde_json::from_str(text).ok()?;
|
||||
let kind = event.get("type")?.as_str()?;
|
||||
match kind {
|
||||
"input_audio_buffer.speech_started" => Some(VoiceEvent::SpeechStarted),
|
||||
"input_audio_buffer.speech_stopped" => Some(VoiceEvent::SpeechStopped),
|
||||
"response.created" => Some(VoiceEvent::ResponseStarted),
|
||||
"response.done" | "response.completed" | "response.cancelled" => {
|
||||
Some(VoiceEvent::ResponseFinished)
|
||||
}
|
||||
"conversation.item.input_audio_transcription.delta"
|
||||
| "response.input_audio_transcription.delta" => {
|
||||
let text = event.get("delta")?.as_str()?.to_string();
|
||||
Some(VoiceEvent::InputTranscript {
|
||||
text,
|
||||
final_: false,
|
||||
})
|
||||
}
|
||||
"conversation.item.input_audio_transcription.completed"
|
||||
| "conversation.item.input_audio_transcription.done"
|
||||
| "response.input_audio_transcription.completed" => {
|
||||
let text = event
|
||||
.get("transcript")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| event.get("text").and_then(Value::as_str))?
|
||||
.to_string();
|
||||
Some(VoiceEvent::InputTranscript {
|
||||
text,
|
||||
final_: true,
|
||||
})
|
||||
}
|
||||
"response.output_audio.delta" | "response.audio.delta" => {
|
||||
let encoded = event.get("delta").and_then(Value::as_str)?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.ok()?;
|
||||
Some(VoiceEvent::AudioPcm(bytes))
|
||||
}
|
||||
"response.output_audio_transcript.delta" | "response.audio_transcript.delta" => {
|
||||
let text = event.get("delta")?.as_str()?.to_string();
|
||||
Some(VoiceEvent::OutputTranscript {
|
||||
text,
|
||||
final_: false,
|
||||
})
|
||||
}
|
||||
"response.output_audio_transcript.done" | "response.audio_transcript.done" => {
|
||||
let text = event
|
||||
.get("transcript")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| event.get("text").and_then(Value::as_str))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(VoiceEvent::OutputTranscript {
|
||||
text,
|
||||
final_: true,
|
||||
})
|
||||
}
|
||||
"response.function_call_arguments.done" => {
|
||||
let call_id = event.get("call_id")?.as_str()?.to_string();
|
||||
let name = event.get("name")?.as_str()?.to_string();
|
||||
let arguments = event
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("{}")
|
||||
.to_string();
|
||||
Some(VoiceEvent::FunctionCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
"error" => {
|
||||
let message = event
|
||||
.pointer("/error/message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| event.get("message").and_then(Value::as_str))
|
||||
.unwrap_or("voice error")
|
||||
.to_string();
|
||||
Some(VoiceEvent::Error { message })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedVoice;
|
||||
|
||||
#[async_trait]
|
||||
impl VoiceRealtime for ScriptedVoice {
|
||||
fn provider(&self) -> VoiceProvider {
|
||||
VoiceProvider::Scripted
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
&self,
|
||||
_request: VoiceConnectRequest,
|
||||
) -> Result<Box<dyn VoiceSocket>, VoiceError> {
|
||||
Ok(Box::new(ScriptedSocket::new()))
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedSocket {
|
||||
incoming: Mutex<mpsc::UnboundedSender<VoiceEvent>>,
|
||||
outgoing: mpsc::UnboundedReceiver<VoiceEvent>,
|
||||
heard: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl ScriptedSocket {
|
||||
fn new() -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
incoming: Mutex::new(tx),
|
||||
outgoing: rx,
|
||||
heard: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn beep() -> Vec<u8> {
|
||||
// 80 ms of 24 kHz PCM16 silence so tests have a non-empty clip.
|
||||
vec![0; 24000 / 12 * 2]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VoiceSocket for ScriptedSocket {
|
||||
async fn send(&self, event: VoiceEvent) -> Result<(), VoiceError> {
|
||||
match event {
|
||||
VoiceEvent::AudioPcm(bytes) if !bytes.is_empty() => {
|
||||
let mut heard = self.heard.lock().await;
|
||||
if !*heard {
|
||||
*heard = true;
|
||||
let tx = self.incoming.lock().await;
|
||||
let _ = tx.send(VoiceEvent::SpeechStarted);
|
||||
let _ = tx.send(VoiceEvent::InputTranscript {
|
||||
text: "hello from the test microphone".into(),
|
||||
final_: true,
|
||||
});
|
||||
let _ = tx.send(VoiceEvent::FunctionCall {
|
||||
call_id: "call_scripted".into(),
|
||||
name: "start_computer_task".into(),
|
||||
arguments: json!({ "prompt": "open the browser" }).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
VoiceEvent::FunctionCallOutput { .. } => {
|
||||
let tx = self.incoming.lock().await;
|
||||
let _ = tx.send(VoiceEvent::OutputTranscript {
|
||||
text: "好,我去電腦上開。".into(),
|
||||
final_: true,
|
||||
});
|
||||
let _ = tx.send(VoiceEvent::AudioPcm(Self::beep()));
|
||||
}
|
||||
VoiceEvent::SpeakNow { text } | VoiceEvent::InjectContext { text } => {
|
||||
let tx = self.incoming.lock().await;
|
||||
let _ = tx.send(VoiceEvent::OutputTranscript {
|
||||
text,
|
||||
final_: true,
|
||||
});
|
||||
let _ = tx.send(VoiceEvent::AudioPcm(Self::beep()));
|
||||
}
|
||||
VoiceEvent::ResponseCreate => {}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Option<VoiceEvent>, VoiceError> {
|
||||
Ok(self.outgoing.recv().await)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voice_tls_config_uses_an_explicit_provider() {
|
||||
let config = rustls::ClientConfig::builder_with_provider(
|
||||
Arc::new(rustls::crypto::ring::default_provider()),
|
||||
).with_safe_default_protocol_versions().unwrap()
|
||||
.with_root_certificates(native_roots().unwrap()).with_no_client_auth();
|
||||
assert!(!config.crypto_provider().cipher_suites.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_audio_uses_json_and_nested_session_settings() {
|
||||
let request = VoiceConnectRequest {
|
||||
api_key: String::new(), model_id: "gpt-realtime".into(),
|
||||
voice_id: "marin".into(), instructions: "test".into(),
|
||||
tools: vec![], history: vec![],
|
||||
};
|
||||
let session: Value = serde_json::from_str(&session_update_json(VoiceProvider::Openai, &request)).unwrap();
|
||||
assert!(session["session"].get("voice").is_none());
|
||||
assert!(session["session"].get("turn_detection").is_none());
|
||||
assert_eq!(session.pointer("/session/audio/input/format/rate"), Some(&json!(24000)));
|
||||
assert_eq!(session.pointer("/session/audio/input/turn_detection/type"), Some(&json!("server_vad")));
|
||||
let event = VoiceEvent::AudioPcm(vec![0, 1, 2, 3]);
|
||||
let Some(Message::Text(text)) = encode_provider_event(VoiceProvider::Openai, &event) else { panic!("expected JSON audio") };
|
||||
let encoded: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(encoded["type"], "input_audio_buffer.append");
|
||||
assert_eq!(encoded["audio"], "AAECAw==");
|
||||
assert!(matches!(encode_provider_event(VoiceProvider::Xai, &event), Some(Message::Binary(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_voice_defaults_and_requires_key() {
|
||||
let missing = resolve_voice(ResolveVoiceRequest {
|
||||
provider: VoiceProvider::Xai,
|
||||
model_id: None,
|
||||
voice_id: None,
|
||||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: None,
|
||||
env: None,
|
||||
},
|
||||
});
|
||||
assert!(matches!(missing, Err(VoiceError::MissingCredential { .. })));
|
||||
|
||||
let ok = resolve_voice(ResolveVoiceRequest {
|
||||
provider: VoiceProvider::Xai,
|
||||
model_id: None,
|
||||
voice_id: None,
|
||||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: Some("sk".into()),
|
||||
env: None,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(ok.model_id, "grok-voice-latest");
|
||||
assert_eq!(ok.voice_id, "eve");
|
||||
assert_eq!(ok.api_key, "sk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_xai_and_openai_event_aliases() {
|
||||
let started = parse_provider_event(
|
||||
r#"{"type":"input_audio_buffer.speech_started"}"#,
|
||||
);
|
||||
assert!(matches!(started, Some(VoiceEvent::SpeechStarted)));
|
||||
|
||||
let transcript = parse_provider_event(
|
||||
r#"{"type":"conversation.item.input_audio_transcription.completed","transcript":"hello"}"#,
|
||||
);
|
||||
match transcript {
|
||||
Some(VoiceEvent::InputTranscript { text, final_ }) => {
|
||||
assert_eq!(text, "hello");
|
||||
assert!(final_);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
|
||||
let old_audio = parse_provider_event(
|
||||
r#"{"type":"response.audio.delta","delta":"AQID"}"#,
|
||||
);
|
||||
assert!(matches!(old_audio, Some(VoiceEvent::AudioPcm(_))));
|
||||
|
||||
let tool = parse_provider_event(
|
||||
r#"{"type":"response.function_call_arguments.done","call_id":"1","name":"start_computer_task","arguments":"{}"}"#,
|
||||
);
|
||||
match tool {
|
||||
Some(VoiceEvent::FunctionCall { name, .. }) => {
|
||||
assert_eq!(name, "start_computer_task");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scripted_voice_emits_transcript_and_tool() {
|
||||
let provider = create_voice(VoiceProvider::Scripted);
|
||||
let mut socket = provider
|
||||
.connect(VoiceConnectRequest {
|
||||
api_key: String::new(),
|
||||
model_id: "scripted-voice".into(),
|
||||
voice_id: "scripted".into(),
|
||||
instructions: "test".into(),
|
||||
tools: computer_voice_tools(),
|
||||
history: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
socket
|
||||
.send(VoiceEvent::AudioPcm(vec![0, 1, 2, 3]))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut kinds = Vec::new();
|
||||
for _ in 0..3 {
|
||||
match socket.recv().await.unwrap() {
|
||||
Some(VoiceEvent::SpeechStarted) => kinds.push("start"),
|
||||
Some(VoiceEvent::InputTranscript { final_: true, .. }) => kinds.push("in"),
|
||||
Some(VoiceEvent::FunctionCall { name, .. }) => {
|
||||
assert_eq!(name, "start_computer_task");
|
||||
kinds.push("tool");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(kinds, ["start", "in", "tool"]);
|
||||
socket
|
||||
.send(VoiceEvent::FunctionCallOutput {
|
||||
call_id: "call_scripted".into(),
|
||||
output: json!({"status":"queued"}).to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
match socket.recv().await.unwrap() {
|
||||
Some(VoiceEvent::OutputTranscript { text, final_ }) => {
|
||||
assert!(final_);
|
||||
assert!(text.contains("電腦"));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match socket.recv().await.unwrap() {
|
||||
Some(VoiceEvent::AudioPcm(bytes)) => assert!(!bytes.is_empty()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use bollard::container::{
|
|||
StartContainerOptions, StopContainerOptions,
|
||||
};
|
||||
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
|
||||
use bollard::models::{HostConfig, PortBinding};
|
||||
use bollard::models::{HostConfig, HostConfigLogConfig, PortBinding};
|
||||
use bollard::network::CreateNetworkOptions;
|
||||
use futures_util::StreamExt;
|
||||
use lazyboy_control::{
|
||||
|
|
@ -117,7 +117,7 @@ impl DockerHost {
|
|||
let mut labels = HashMap::new();
|
||||
labels.insert("lazyboy.homeKey".into(), home_key.to_string());
|
||||
labels.insert("lazyboy.spaceId".into(), space_id.to_string());
|
||||
labels.insert("lazyboy.controlVersion".into(), "2".into());
|
||||
labels.insert("lazyboy.controlVersion".into(), "3".into());
|
||||
|
||||
let mut port_bindings = HashMap::new();
|
||||
let mut exposed = HashMap::new();
|
||||
|
|
@ -134,13 +134,37 @@ impl DockerHost {
|
|||
}
|
||||
|
||||
let host_config = HostConfig {
|
||||
binds: Some(vec![format!("{home_path}:{HOME}")]),
|
||||
log_config: Some(HostConfigLogConfig {
|
||||
typ: Some("json-file".into()),
|
||||
config: Some(HashMap::from([
|
||||
("max-size".into(), "10m".into()),
|
||||
("max-file".into(), "3".into()),
|
||||
])),
|
||||
}),
|
||||
binds: Some(
|
||||
std::iter::once(format!("{home_path}:{HOME}"))
|
||||
.chain(lxcfs_binds())
|
||||
.collect(),
|
||||
),
|
||||
port_bindings: Some(port_bindings),
|
||||
memory: Some(computer_memory_bytes()),
|
||||
nano_cpus: Some(computer_nano_cpus()),
|
||||
pids_limit: Some(computer_pids_limit()),
|
||||
cap_drop: Some(vec!["ALL".into()]),
|
||||
security_opt: Some(vec!["no-new-privileges:true".into()]),
|
||||
cap_drop: if computer_sudo_enabled() {
|
||||
None
|
||||
} else {
|
||||
Some(vec!["ALL".into()])
|
||||
},
|
||||
cap_add: if computer_sudo_enabled() {
|
||||
None
|
||||
} else {
|
||||
Some(vec!["SETUID".into(), "SETGID".into()])
|
||||
},
|
||||
security_opt: if computer_sudo_enabled() {
|
||||
None
|
||||
} else {
|
||||
Some(vec!["no-new-privileges:true".into()])
|
||||
},
|
||||
privileged: Some(false),
|
||||
shm_size: Some(512 * 1024 * 1024),
|
||||
network_mode: Some(network),
|
||||
|
|
@ -149,7 +173,10 @@ impl DockerHost {
|
|||
|
||||
let config = Config {
|
||||
image: Some(self.image.clone()),
|
||||
user: Some("1000:1000".into()),
|
||||
// The entrypoint starts as root so it can apply the env-only sudo
|
||||
// policy, then drops the desktop process to the unprivileged user.
|
||||
// Exec requests below still run explicitly as 1000:1000.
|
||||
user: None,
|
||||
hostname: Some(name.clone()),
|
||||
env: Some(vec![
|
||||
"DISPLAY=:1".into(),
|
||||
|
|
@ -158,6 +185,10 @@ impl DockerHost {
|
|||
"LAZYBOY_CONTROL_TOKEN={}",
|
||||
scoped_control_token(&self.control_token, home_key)
|
||||
),
|
||||
format!(
|
||||
"LAZYBOY_COMPUTER_SUDO={}",
|
||||
if computer_sudo_enabled() { "true" } else { "false" }
|
||||
),
|
||||
]),
|
||||
labels: Some(labels),
|
||||
exposed_ports: Some(exposed),
|
||||
|
|
@ -624,7 +655,7 @@ PY"#,
|
|||
.and_then(|c| c.labels.as_ref())
|
||||
.and_then(|l| l.get("lazyboy.controlVersion"))
|
||||
.map(String::as_str)
|
||||
!= Some("2")
|
||||
!= Some("3")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
|
@ -999,6 +1030,26 @@ fn computer_pids_limit() -> i64 {
|
|||
.unwrap_or(2048)
|
||||
}
|
||||
|
||||
fn computer_sudo_enabled() -> bool {
|
||||
matches!(std::env::var("LAZYBOY_COMPUTER_SUDO").as_deref(), Ok("1" | "true" | "yes"))
|
||||
}
|
||||
|
||||
/// LXCFS supplies cgroup-aware /proc views so tools such as htop and free
|
||||
/// report the Agent container's quota instead of the Docker host. It is
|
||||
/// optional because Docker Desktop (macOS/Windows) does not ship LXCFS.
|
||||
fn lxcfs_binds() -> impl Iterator<Item = String> {
|
||||
let root = std::env::var("LAZYBOY_LXCFS_ROOT").ok();
|
||||
["cpuinfo", "loadavg", "meminfo", "stat", "swaps", "uptime"]
|
||||
.into_iter()
|
||||
.filter_map(move |name| {
|
||||
let root = root.as_deref()?;
|
||||
let source = PathBuf::from(root).join("proc").join(name);
|
||||
source
|
||||
.is_file()
|
||||
.then(|| format!("{}:/proc/{name}:ro", source.display()))
|
||||
})
|
||||
}
|
||||
|
||||
fn docker_already(error: &str, needle: &str) -> bool {
|
||||
let error = error.to_ascii_lowercase();
|
||||
error.contains("409") || error.contains(needle)
|
||||
|
|
|
|||
|
|
@ -47,12 +47,18 @@ services:
|
|||
environment:
|
||||
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
|
||||
LAZYBOY_COMPUTER_IMAGE: lazyboy/computer:local
|
||||
LAZYBOY_COMPUTER_CPUS: ${LAZYBOY_COMPUTER_CPUS:-2}
|
||||
LAZYBOY_COMPUTER_MEMORY_MB: ${LAZYBOY_COMPUTER_MEMORY_MB:-2048}
|
||||
LAZYBOY_COMPUTER_PIDS: ${LAZYBOY_COMPUTER_PIDS:-2048}
|
||||
LAZYBOY_COMPUTER_SUDO: ${LAZYBOY_COMPUTER_SUDO:-false}
|
||||
LAZYBOY_LXCFS_ROOT: /var/lib/lxcfs
|
||||
SUPERVISOR_BIND: 0.0.0.0:7091
|
||||
DATA_DIR: /data
|
||||
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/data
|
||||
- ${LAZYBOY_LXCFS_ROOT:-./data/lxcfs}:/var/lib/lxcfs:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
|
|
@ -88,6 +94,12 @@ services:
|
|||
LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed
|
||||
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}
|
||||
LAZYBOY_MEMORY_BYTE_BUDGET: ${LAZYBOY_MEMORY_BYTE_BUDGET:-6000}
|
||||
LAZYBOY_EVENT_RETENTION_DAYS: ${LAZYBOY_EVENT_RETENTION_DAYS:-30}
|
||||
LAZYBOY_CHECKPOINT_RETENTION_DAYS: ${LAZYBOY_CHECKPOINT_RETENTION_DAYS:-7}
|
||||
LAZYBOY_RUN_RETENTION_DAYS: ${LAZYBOY_RUN_RETENTION_DAYS:-90}
|
||||
LAZYBOY_RECORDING_RETENTION_DAYS: ${LAZYBOY_RECORDING_RETENTION_DAYS:-30}
|
||||
LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS: ${LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS:-90}
|
||||
LAZYBOY_DB_WARN_MB: ${LAZYBOY_DB_WARN_MB:-1024}
|
||||
LAZYBOY_WEB_DIR: /web
|
||||
LAZYBOY_SCREEN_UPSTREAM: host.docker.internal
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ WORKDIR /src/apps/web
|
|||
COPY apps/web/package.json apps/web/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
|
||||
COPY apps/web/index.html apps/web/tsconfig.json apps/web/vite.config.ts ./
|
||||
COPY apps/web/public public
|
||||
COPY apps/web/src src
|
||||
RUN npm run build \
|
||||
&& apt-get update \
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ RUN printf '%s\n' \
|
|||
procps \
|
||||
python3 \
|
||||
python3-websocket \
|
||||
sudo \
|
||||
gosu \
|
||||
util-linux \
|
||||
websockify \
|
||||
wmctrl \
|
||||
|
|
@ -97,6 +99,7 @@ RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
|
|||
&& chown -R 1000:1000 /home/lazyboy /tmp/lazyboy
|
||||
|
||||
COPY --from=controld --chmod=755 /lazyboy-controld /usr/local/bin/lazyboy-controld
|
||||
COPY --chmod=755 image/computer/rotate-logs.py /usr/local/bin/lazyboy-rotate-logs
|
||||
COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen
|
||||
COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser
|
||||
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal
|
||||
|
|
@ -132,11 +135,12 @@ COPY --chmod=644 image/computer/xfce/helper-browser.desktop /usr/share/xfce4/hel
|
|||
COPY --chmod=755 image/computer/chromium /usr/local/bin/chromium
|
||||
RUN sed -i 's|^Exec=/usr/bin/chromium|Exec=/usr/local/bin/lazyboy-browser|' /usr/share/applications/chromium.desktop || true
|
||||
COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
|
||||
COPY --chmod=755 image/computer/entrypoint.sh /usr/local/bin/lazyboy-entrypoint
|
||||
|
||||
USER 1000:1000
|
||||
USER root
|
||||
ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \
|
||||
LANG=zh_TW.UTF-8 LC_ALL=zh_TW.UTF-8 LANGUAGE=zh_TW:zh:en \
|
||||
GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
|
||||
WORKDIR /home/lazyboy
|
||||
EXPOSE 6080 6081 6082 6083 6084 6085 6086 6087
|
||||
CMD ["/usr/local/bin/lazyboy-computer"]
|
||||
CMD ["/usr/local/bin/lazyboy-entrypoint"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${LAZYBOY_COMPUTER_SUDO:-false}" =~ ^(1|true|yes)$ ]]; then
|
||||
printf 'lazyboy ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/lazyboy
|
||||
chmod 0440 /etc/sudoers.d/lazyboy
|
||||
else
|
||||
rm -f /etc/sudoers.d/lazyboy
|
||||
fi
|
||||
|
||||
exec gosu lazyboy /usr/local/bin/lazyboy-computer
|
||||
|
|
@ -103,7 +103,7 @@ start_xvfb() {
|
|||
return 0
|
||||
fi
|
||||
rm -f "/tmp/.X${number}-lock" "/tmp/.X11-unix/X${number}"
|
||||
Xvfb "$display" -screen 0 1280x800x24 -ac +extension RANDR +render -noreset >"${log}-xvfb.log" 2>&1 &
|
||||
Xvfb "$display" -screen 0 1280x800x24 -ac +extension RANDR +render -noreset >>"${log}-xvfb.log" 2>&1 &
|
||||
echo $! > "${log}-xvfb.pid"
|
||||
wait_display "$display"
|
||||
}
|
||||
|
|
@ -141,11 +141,11 @@ start_atspi() {
|
|||
fi
|
||||
done
|
||||
if [[ -n "$launcher" ]]; then
|
||||
DISPLAY="$display" "$launcher" --launch-immediately >"${log}-atspi-bus.log" 2>&1 &
|
||||
DISPLAY="$display" "$launcher" --launch-immediately >>"${log}-atspi-bus.log" 2>&1 &
|
||||
echo $! >"${log}-atspi-bus.pid"
|
||||
fi
|
||||
if [[ -n "$registry" ]]; then
|
||||
DISPLAY="$display" "$registry" >"${log}-atspi-registry.log" 2>&1 &
|
||||
DISPLAY="$display" "$registry" >>"${log}-atspi-registry.log" 2>&1 &
|
||||
echo $! >"${log}-atspi-registry.pid"
|
||||
fi
|
||||
sleep 0.2
|
||||
|
|
@ -175,12 +175,12 @@ start_desktop() {
|
|||
xfconfd --daemon >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v xfwm4 >/dev/null 2>&1; then
|
||||
xfwm4 --compositor=off --display="$display" --sm-client-disable >"${log}-wm.log" 2>&1 &
|
||||
xfwm4 --compositor=off --display="$display" --sm-client-disable >>"${log}-wm.log" 2>&1 &
|
||||
echo $! >"${log}-wm.pid"
|
||||
sleep 0.3
|
||||
xfdesktop --disable-wm-check --sm-client-disable >"${log}-desktop.log" 2>&1 &
|
||||
xfdesktop --disable-wm-check --sm-client-disable >>"${log}-desktop.log" 2>&1 &
|
||||
echo $! >"${log}-desktop.pid"
|
||||
xfce4-panel --disable-wm-check --sm-client-disable >"${log}-panel.log" 2>&1 &
|
||||
xfce4-panel --disable-wm-check --sm-client-disable >>"${log}-panel.log" 2>&1 &
|
||||
echo $! >"${log}-panel.pid"
|
||||
else
|
||||
echo "XFCE is missing" >&2
|
||||
|
|
@ -196,7 +196,7 @@ start_vnc() {
|
|||
local log="$4"
|
||||
if ! port_open "$vnc_port"; then
|
||||
x11vnc -display "$display" -forever -shared -nopw -listen 127.0.0.1 -rfbport "$vnc_port" \
|
||||
-xkb -repeat -cursor arrow -noxdamage -ncache 0 >"${log}-x11vnc.log" 2>&1 &
|
||||
-xkb -repeat -cursor arrow -noxdamage -ncache 0 >>"${log}-x11vnc.log" 2>&1 &
|
||||
fi
|
||||
if ! port_open "$view_port"; then
|
||||
local novnc=/usr/share/novnc
|
||||
|
|
@ -205,7 +205,7 @@ start_vnc() {
|
|||
return 1
|
||||
fi
|
||||
websockify --heartbeat=30 --web="$novnc" "0.0.0.0:${view_port}" "127.0.0.1:${vnc_port}" \
|
||||
>"${log}-novnc.log" 2>&1 &
|
||||
>>"${log}-novnc.log" 2>&1 &
|
||||
fi
|
||||
wait_port "$vnc_port"
|
||||
wait_port "$view_port"
|
||||
|
|
@ -217,7 +217,7 @@ start_xterm() {
|
|||
if alive_pidfile "${log}-xterm.pid"; then
|
||||
return 0
|
||||
fi
|
||||
DISPLAY="$display" SHELL=/bin/zsh lazyboy-terminal >"${log}-xterm.log" 2>&1 &
|
||||
DISPLAY="$display" SHELL=/bin/zsh lazyboy-terminal >>"${log}-xterm.log" 2>&1 &
|
||||
echo $! > "${log}-xterm.pid"
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ start_browser() {
|
|||
fi
|
||||
rm -f "$profile/SingletonLock" "$profile/SingletonCookie" "$profile/SingletonSocket"
|
||||
DISPLAY="$display" HOME="$HOME" LAZYBOY_BROWSER_PROFILE="$profile" \
|
||||
lazyboy-browser https://duckduckgo.com >"${log}-browser.log" 2>&1 &
|
||||
lazyboy-browser https://duckduckgo.com >>"${log}-browser.log" 2>&1 &
|
||||
echo $! > "${log}-browser.pid"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Rotate only LazyBoy service logs; preserve open file descriptors (copytruncate)."""
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import time
|
||||
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
BACKUPS = 3
|
||||
|
||||
|
||||
def rotate(root: Path, limit: int = MAX_BYTES):
|
||||
for path in root.glob('*.log'):
|
||||
try:
|
||||
fd = os.open(path, os.O_RDWR | os.O_NOFOLLOW)
|
||||
with os.fdopen(fd, 'r+b') as source:
|
||||
info = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size <= limit:
|
||||
continue
|
||||
# Keep a bounded tail even after an unusually large burst.
|
||||
source.seek(-limit, os.SEEK_END)
|
||||
tail = source.read(limit)
|
||||
for n in range(BACKUPS - 1, 0, -1):
|
||||
previous = Path(f'{path}.{n}')
|
||||
if previous.exists() or previous.is_symlink():
|
||||
os.replace(previous, Path(f'{path}.{n + 1}'))
|
||||
backup_fd = os.open(f'{path}.1', os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(backup_fd, 'wb') as backup:
|
||||
backup.write(tail)
|
||||
source.truncate(0)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f'log rotation skipped {path.name}: {error}', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
root = Path('/tmp/lazyboy')
|
||||
root.mkdir(exist_ok=True)
|
||||
with (root / 'log-rotation.lock').open('w') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return
|
||||
while True:
|
||||
rotate(root)
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -19,8 +19,10 @@ rm -f /tmp/lazyboy/ready
|
|||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
cd "$HOME"
|
||||
|
||||
python3 /usr/local/bin/lazyboy-rotate-logs &
|
||||
|
||||
if [[ -n "${LAZYBOY_CONTROL_TOKEN:-}" ]]; then
|
||||
lazyboy-controld >/tmp/lazyboy/control.log 2>&1 &
|
||||
lazyboy-controld >>/tmp/lazyboy/control.log 2>&1 &
|
||||
fi
|
||||
|
||||
if command -v dbus-launch >/dev/null 2>&1; then
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE spaces
|
||||
ADD COLUMN IF NOT EXISTS voice_provider TEXT,
|
||||
ADD COLUMN IF NOT EXISTS voice_model_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS voice_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS voice_api_key TEXT;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE spaces
|
||||
ADD COLUMN voice_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- Age scans use small batches rather than locking or scanning the full database.
|
||||
CREATE INDEX IF NOT EXISTS events_retention_idx ON events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS runs_retention_idx ON runs((COALESCE(completed_at,updated_at)))
|
||||
WHERE status IN ('completed','failed','cancelled');
|
||||
CREATE INDEX IF NOT EXISTS recording_retention_idx ON taught_skills(updated_at)
|
||||
WHERE status IN ('saved','failed');
|
||||
CREATE INDEX IF NOT EXISTS memory_revisions_age_idx ON memory_revisions(created_at);
|
||||
CREATE INDEX IF NOT EXISTS memory_deleted_age_idx ON memory_items(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
-- Encourage normal background vacuum to reuse space after regular small deletes.
|
||||
ALTER TABLE events SET (autovacuum_vacuum_scale_factor=0.05, autovacuum_vacuum_threshold=1000);
|
||||
ALTER TABLE runs SET (autovacuum_vacuum_scale_factor=0.05, autovacuum_vacuum_threshold=1000);
|
||||
ALTER TABLE memory_revisions SET (autovacuum_vacuum_scale_factor=0.05, autovacuum_vacuum_threshold=1000);
|
||||
|
|
@ -3,9 +3,19 @@ import assert from 'node:assert/strict';
|
|||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import ts from '../apps/web/node_modules/typescript/lib/typescript.js';
|
||||
function loadCatalog(file){
|
||||
const code=ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const sandbox={exports:{},require:()=>({})};
|
||||
vm.runInNewContext(code,sandbox);
|
||||
return sandbox.exports.zhTW||sandbox.exports.en;
|
||||
}
|
||||
const zhTW=loadCatalog('apps/web/src/locales/zh-TW.ts');
|
||||
const en=loadCatalog('apps/web/src/locales/en.ts');
|
||||
const catalogs={'zh-TW':zhTW,en};
|
||||
const i18n={locale:'en',t:(key,params)=>{const message=catalogs[i18n.locale]?.[key]??key;return params?message.replace(/\{(\w+)\}/g,(m,name)=>Object.hasOwn(params,name)?String(params[name]):m):message},getLocale:()=>i18n.locale};
|
||||
const raw=fs.readFileSync('apps/web/src/schedule.tsx','utf8');
|
||||
const js=ts.transpileModule(raw,{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText;
|
||||
const box={exports:{},require:()=>({t:x=>x})};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset}=box.exports;
|
||||
const box={exports:{},require:()=>i18n};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset,describeCron,describeCronHuman}=box.exports;
|
||||
test('fixed intervals retain exact elapsed units',()=>{
|
||||
for (const unit of ['minutes','hours','days']) {const p={...defaultCronPreset(),freq:'Interval',n:45,unit};const cron=cronFromPreset(p);const restored=presetFromCron(cron);assert.equal(restored.n,45);assert.equal(restored.unit,unit);}
|
||||
});
|
||||
|
|
@ -43,6 +53,29 @@ const mdBox={exports:{},require:(name)=>{
|
|||
}};
|
||||
vm.runInNewContext(mdJs,mdBox);
|
||||
const {sanitizeMarkdownUrl}=mdBox.exports;
|
||||
const audioJs=ts.transpileModule(fs.readFileSync('apps/web/src/call-audio.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const audioBox={exports:{},require:()=>{throw new Error('unexpected import')}};
|
||||
vm.runInNewContext(audioJs,audioBox);
|
||||
const {floatToPcm16,pcm16ToFloat,resample,VOICE_SAMPLE_RATE,CAPTURE_CHUNK_SAMPLES}=audioBox.exports;
|
||||
function fakePlaybackContext(rendered){
|
||||
return {
|
||||
sampleRate:VOICE_SAMPLE_RATE,currentTime:0,
|
||||
createBuffer:(_channels,length,rate)=>{const data=new Float32Array(length);rendered.push(data);return {length,duration:length/rate,getChannelData:()=>data}},
|
||||
createBufferSource:()=>({buffer:null,connect(){},start(){},stop(){}}),
|
||||
};
|
||||
}
|
||||
test('pcm16 roundtrip keeps amplitude sign and resample identity at 24 kHz',()=>{
|
||||
const source=new Float32Array([0,0.5,-0.5,1,-1]);
|
||||
const bytes=floatToPcm16(source);
|
||||
const back=pcm16ToFloat(bytes.buffer);
|
||||
assert.equal(back.length,source.length);
|
||||
assert.ok(back[1]>0.49&&back[1]<0.51);
|
||||
assert.ok(back[2]< -0.49&&back[2]> -0.51);
|
||||
const same=resample(source,VOICE_SAMPLE_RATE,VOICE_SAMPLE_RATE);
|
||||
assert.equal(same,source);
|
||||
const down=resample(new Float32Array([0,1,0,1]),48000,24000);
|
||||
assert.equal(down.length,2);
|
||||
});
|
||||
test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
|
||||
assert.equal(sanitizeMarkdownUrl('https://example.com/docs'),'https://example.com/docs');
|
||||
assert.equal(sanitizeMarkdownUrl('mailto:hi@example.com'),'mailto:hi@example.com');
|
||||
|
|
@ -51,3 +84,63 @@ test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
|
|||
assert.equal(sanitizeMarkdownUrl('data:text/html,<script>alert(1)</script>'),undefined);
|
||||
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
|
||||
});
|
||||
|
||||
test('zh-TW and en catalogs share keys and placeholders',()=>{
|
||||
const zhKeys=Object.keys(zhTW).sort();
|
||||
const enKeys=Object.keys(en).sort();
|
||||
assert.deepEqual(enKeys,zhKeys);
|
||||
for(const key of zhKeys){
|
||||
const slots=s=>[...String(s).matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort();
|
||||
assert.deepEqual(slots(en[key]),slots(zhTW[key]),key);
|
||||
assert.notEqual(String(en[key]).trim(),'');
|
||||
assert.notEqual(String(zhTW[key]).trim(),'');
|
||||
}
|
||||
});
|
||||
|
||||
test('describeCron and stored Chinese human strings follow the UI locale',()=>{
|
||||
i18n.locale='en';
|
||||
assert.equal(describeCron('0 9 * * *'),'Daily at 9:00 AM');
|
||||
assert.equal(describeCron('0 9 * * 1-5'),'Weekdays at 9:00 AM');
|
||||
assert.equal(describeCronHuman('每天 09:00'),'Daily at 9:00 AM');
|
||||
assert.equal(describeCronHuman('每隔 45 分鐘(固定間隔)'),'Every 45 minutes (elapsed)');
|
||||
i18n.locale='zh-TW';
|
||||
assert.equal(describeCron('0 9 * * *'),'每天 09:00');
|
||||
assert.equal(describeCronHuman('Daily at 9:00 AM'),'每天 09:00');
|
||||
});
|
||||
|
||||
test('hanging up before microphone permission resolves releases the late stream',async()=>{
|
||||
let grant; let stopped=0;
|
||||
const context={exports:{},navigator:{mediaDevices:{getUserMedia:()=>new Promise(resolve=>grant=resolve)}}};
|
||||
vm.runInNewContext(audioJs,context);
|
||||
const audio=new context.exports.CallAudio({onCapture(){},onError(){}});
|
||||
const starting=audio.start();
|
||||
audio.stop();
|
||||
grant({getTracks:()=>[{stop(){stopped++;}}]});
|
||||
await starting;
|
||||
assert.equal(stopped,1);
|
||||
});
|
||||
|
||||
test('odd-sized audio frames stay sample-aligned instead of turning into noise',()=>{
|
||||
const rendered=[];
|
||||
const audio=new audioBox.exports.CallAudio({onCapture(){},onError(){}});
|
||||
audio.context=fakePlaybackContext(rendered);
|
||||
const source=new Int16Array([1000,-1000,2000,-2000,3000,-3000,4000,-4000]);
|
||||
const bytes=new Uint8Array(source.buffer);
|
||||
let offset=0;
|
||||
for(const size of [3,5,7,1]){audio.play(bytes.slice(offset,offset+size).buffer);offset+=size}
|
||||
const played=rendered.flatMap(chunk=>[...chunk]);
|
||||
const expected=[...pcm16ToFloat(bytes.slice().buffer)];
|
||||
assert.equal(played.length,expected.length);
|
||||
expected.forEach((value,index)=>assert.ok(Math.abs(played[index]-value)<1e-6,`sample ${index}`));
|
||||
});
|
||||
|
||||
test('microphone frames are batched instead of sent one websocket message per block',()=>{
|
||||
const sent=[];
|
||||
const audio=new audioBox.exports.CallAudio({onCapture:pcm=>sent.push(pcm.length),onError(){}});
|
||||
const block=new Float32Array(64);
|
||||
const blocksPerChunk=CAPTURE_CHUNK_SAMPLES/block.length;
|
||||
for(let i=0;i<blocksPerChunk-1;i++)audio.queueCapture(block);
|
||||
assert.deepEqual(sent,[]);
|
||||
audio.queueCapture(block);
|
||||
assert.deepEqual(sent,[CAPTURE_CHUNK_SAMPLES*2]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('rotation', Path(__file__).resolve().parents[1] / 'image/computer/rotate-logs.py')
|
||||
rotation = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rotation)
|
||||
|
||||
|
||||
class RotationTests(unittest.TestCase):
|
||||
def test_bounds_backups_and_keeps_live_writer(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
log = root / 'control.log'
|
||||
with log.open('ab', buffering=0) as writer:
|
||||
for n in range(6):
|
||||
writer.write(bytes([65+n]) * 40)
|
||||
rotation.rotate(root, 16)
|
||||
self.assertEqual(log.stat().st_size, 0)
|
||||
self.assertEqual(Path(f'{log}.1').read_bytes(), bytes([65+n])*16)
|
||||
writer.write(b'live')
|
||||
self.assertEqual(log.read_bytes(), b'live')
|
||||
self.assertEqual(len(list(root.glob('*.log.*'))), 3)
|
||||
rotation.rotate(root, 16)
|
||||
self.assertEqual(log.read_bytes(), b'live')
|
||||
|
||||
def test_does_not_follow_symlinks_or_touch_other_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
target = root / 'notes.txt'
|
||||
target.write_bytes(b'important' * 20)
|
||||
(root / 'control.log').symlink_to(target)
|
||||
rotation.rotate(root, 16)
|
||||
self.assertEqual(target.read_bytes(), b'important' * 20)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"""Integration regression: run production cleanup SQL in an isolated disposable DB.
|
||||
Requires the project's Postgres Compose service; never modifies the app database.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = 'retention_test_' + uuid.uuid4().hex
|
||||
|
||||
|
||||
def sql(text, database=DB):
|
||||
result = subprocess.run(['docker','compose','exec','-T','postgres','psql','-X','-q','-v','ON_ERROR_STOP=1','-U','lazyboy','-d',database], input=text, text=True, capture_output=True, cwd=ROOT)
|
||||
if result.returncode:
|
||||
raise AssertionError(result.stderr)
|
||||
|
||||
|
||||
def check(condition):
|
||||
sql(f"DO $$ BEGIN IF NOT ({condition}) THEN RAISE EXCEPTION 'retention assertion failed'; END IF; END $$;")
|
||||
|
||||
|
||||
def clean(name, age, batch=1000):
|
||||
query = (ROOT/'crates/api/src/retention'/f'{name}.sql').read_text()
|
||||
sql(query.replace('$1',str(age)).replace('$2',str(batch))+';')
|
||||
|
||||
|
||||
sql(f'CREATE DATABASE {DB};','postgres')
|
||||
try:
|
||||
for migration in sorted((ROOT/'migrations').glob('*.sql')):
|
||||
sql(migration.read_text())
|
||||
sql("""
|
||||
INSERT INTO users(id,name) VALUES ('u','test');
|
||||
INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test');
|
||||
INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key) VALUES ('c','s','u','bot','c','c');
|
||||
INSERT INTO bots(id,space_id,user_id,name) VALUES ('b','s','u','test');
|
||||
INSERT INTO threads(id,space_id,bot_id,user_id) VALUES ('t','s','b','u');
|
||||
INSERT INTO messages(id,thread_id,seq,role,body,created_at) VALUES ('m','t',1,'user','keep my conversation',now()-interval '1 year');
|
||||
INSERT INTO events(id,thread_id,seq,type,created_at)
|
||||
SELECT 'e'||n,'t',n,'test',now()-interval '31 days' FROM generate_series(1,5) n;
|
||||
INSERT INTO events(id,thread_id,seq,type) VALUES ('recent','t',6,'test');
|
||||
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,checkpoint,updated_at,completed_at)
|
||||
SELECT status,'s','b','t','u',status,'{"harnessHistory":["debug"]}',now()-interval '100 days',
|
||||
CASE WHEN status IN ('completed','failed','cancelled') THEN now()-interval '100 days' ELSE NULL END
|
||||
FROM unnest(ARRAY['completed','failed','cancelled','running','queued','leased','waiting_input','waiting_takeover']) status;
|
||||
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,checkpoint,updated_at,completed_at)
|
||||
VALUES ('recent-completed','s','b','t','u','completed','{"keep":true}',now(),now());
|
||||
INSERT INTO memory_items(id,space_id,user_id,bot_id,content,revision,source_run_id)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001','s','u','b','keep current memory',15,'completed');
|
||||
INSERT INTO memory_revisions(memory_id,revision,space_id,user_id,bot_id,content,importance,action)
|
||||
SELECT '00000000-0000-0000-0000-000000000001',n,'s','u','b','version',.5,'update' FROM generate_series(1,15) n;
|
||||
UPDATE memory_revisions SET created_at=now()-interval '100 days' WHERE revision IN (6,15);
|
||||
INSERT INTO memory_items(id,space_id,user_id,bot_id,content,deleted_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000002','s','u','b','deleted old',now()-interval '100 days'),
|
||||
('00000000-0000-0000-0000-000000000003','s','u','b','deleted recent',now());
|
||||
INSERT INTO taught_skills(id,space_id,user_id,bot_id,goal,status,playbook,recording,updated_at)
|
||||
SELECT status,'s','u','b','goal',status,'{"keep":true}','{"frames":["raw"]}',now()-interval '100 days'
|
||||
FROM unnest(ARRAY['saved','failed','draft','drafting','recording']) status;
|
||||
INSERT INTO computer_profile_locks(computer_id,profile_key,bot_id,run_id,expires_at)
|
||||
VALUES ('c','old','b','completed',now()-interval '10 days'),('c','active','b','running',now()-interval '10 days');
|
||||
INSERT INTO computer_execution_leases(id,computer_id,bot_id,run_id,expires_at)
|
||||
VALUES ('old','c','b','completed',now()-interval '10 days'),('active','c','b2','running',now()-interval '10 days');
|
||||
""")
|
||||
clean('events',30,2)
|
||||
check('(SELECT count(*) FROM events)=4')
|
||||
clean('events',30)
|
||||
check("(SELECT count(*) FROM events)=1 AND EXISTS(SELECT 1 FROM events WHERE id='recent')")
|
||||
clean('checkpoints',7)
|
||||
check("(SELECT count(*) FROM runs WHERE checkpoint='{}')=3")
|
||||
clean('runs',90)
|
||||
check('(SELECT count(*) FROM runs)=6')
|
||||
check("EXISTS(SELECT 1 FROM memory_items WHERE content='keep current memory' AND source_run_id IS NULL)")
|
||||
clean('recordings',30)
|
||||
check("(SELECT count(*) FROM taught_skills WHERE recording='{}')=3 AND (SELECT count(*) FROM taught_skills WHERE playbook->>'keep'='true')=5")
|
||||
clean('revisions',90)
|
||||
check('(SELECT count(*) FROM memory_revisions)=9 AND EXISTS(SELECT 1 FROM memory_revisions WHERE revision=15)')
|
||||
clean('deleted_memories',90)
|
||||
check('(SELECT count(*) FROM memory_items)=2')
|
||||
clean('leases',7)
|
||||
clean('profile_locks',7)
|
||||
check("(SELECT count(*) FROM computer_execution_leases)=1 AND EXISTS(SELECT 1 FROM computer_execution_leases WHERE id='active')")
|
||||
check("(SELECT count(*) FROM computer_profile_locks)=1 AND EXISTS(SELECT 1 FROM computer_profile_locks WHERE profile_key='active')")
|
||||
check("EXISTS(SELECT 1 FROM messages WHERE body='keep my conversation')")
|
||||
for name,age in [('events',30),('checkpoints',7),('runs',90),('recordings',30),('revisions',90),('deleted_memories',90),('leases',7),('profile_locks',7)]:
|
||||
clean(name,age)
|
||||
check('(SELECT count(*) FROM runs)=6 AND (SELECT count(*) FROM memory_revisions)=9')
|
||||
print('PASS: all 8 retention rules; bounded batches; live work and user content preserved; repeat-safe')
|
||||
finally:
|
||||
sql(f'DROP DATABASE {DB};','postgres')
|
||||
Loading…
Reference in New Issue