add new feat

This commit is contained in:
王性驊 2026-09-04 17:08:56 +08:00
parent 4ed1ab3b91
commit 7d87dd5b3c
57 changed files with 4922 additions and 838 deletions

View File

@ -1,4 +1,6 @@
XAI_API_KEY= XAI_API_KEY=
OPENCODE_GO_API_KEY=
OPENAI_API_KEY=
# Generate independent values with: openssl rand -hex 32 # Generate independent values with: openssl rand -hex 32
LAZYBOY_APP_TOKEN= LAZYBOY_APP_TOKEN=
SANDBOX_SUPERVISOR_TOKEN= SANDBOX_SUPERVISOR_TOKEN=

1
Cargo.lock generated
View File

@ -2060,6 +2060,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"hex", "hex",
"image",
"lazyboy-contracts", "lazyboy-contracts",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -26,6 +26,7 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rig-core = "0.42" rig-core = "0.42"
async-trait = "0.1" async-trait = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] }

View File

@ -69,4 +69,4 @@ For frontend development, run `npm install && npm run dev` in `apps/web`, then o
The standalone supervisor listens on `127.0.0.1:7091` by default. Docker Compose reaches it internally as `supervisor:7091`; there is intentionally no host port `7092`. The standalone supervisor listens on `127.0.0.1:7091` by default. Docker Compose reaches it internally as `supervisor:7091`; there is intentionally no host port `7092`.
Model providers: v1 talks to xAI (`XAI_API_KEY`). `openai` / `anthropic` / `openrouter` are reserved on the factory and return `unsupported_provider`. Model providers: set the workspace default in 本機工作區 → 設定. v1 supports xAI (`XAI_API_KEY`), OpenCode Go (`OPENCODE_GO_API_KEY` or a key saved in settings), and a self-hosted OpenAI-compatible endpoint (base URL + optional key). Keys saved in the UI are stored on the local workspace and take precedence over env vars.

View File

@ -8,8 +8,9 @@
"name": "lazyboy-web", "name": "lazyboy-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0", "@fontsource/huninn": "^5.3.0",
"blobshape": "^1.0.0", "blobatar": "^2.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-useanimations": "^2.10.0" "react-useanimations": "^2.10.0"
@ -304,6 +305,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@blobatar/react": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@blobatar/react/-/react-2.7.0.tgz",
"integrity": "sha512-rysDM1+2qoeIwIezVw/em+ZxOMyqqjqJOGNEvtOOU1nFp0jmzyEjlXtJTDM6xPKamq59pq6wcG9+mHIUZV572A==",
"license": "MIT",
"peerDependencies": {
"blobatar": "2.x",
"react": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -1327,11 +1338,23 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/blobshape": { "node_modules/blobatar": {
"version": "1.0.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/blobshape/-/blobshape-1.0.0.tgz", "resolved": "https://registry.npmjs.org/blobatar/-/blobatar-2.7.0.tgz",
"integrity": "sha512-GUD6tUIvj7k8zLSMtfdM1RyusWDjFqYL9g6J6QIgwxbVjL3b6mbRz5peerDc+6kbZX/14BSr3ed9sizrJKdkQQ==", "integrity": "sha512-tL1p2J3dXE/Z9U/ITwHqU7HBHmTH+Am+hif1J1hWAhvFT1VYC2HG+eXtN7081yuY+L2lrp9bs/VaF7dVH3S2jw==",
"license": "MIT" "license": "MIT",
"peerDependencies": {
"react": ">=18",
"vue": ">=3"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",

View File

@ -9,8 +9,9 @@
"typecheck": "tsc --noEmit --pretty false" "typecheck": "tsc --noEmit --pretty false"
}, },
"dependencies": { "dependencies": {
"@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0", "@fontsource/huninn": "^5.3.0",
"blobshape": "^1.0.0", "blobatar": "^2.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-useanimations": "^2.10.0" "react-useanimations": "^2.10.0"

View File

@ -16,39 +16,37 @@ import visibility from "react-useanimations/lib/visibility";
import visibility2 from "react-useanimations/lib/visibility2"; import visibility2 from "react-useanimations/lib/visibility2";
import searchToX from "react-useanimations/lib/searchToX"; import searchToX from "react-useanimations/lib/searchToX";
import { api, ApiError } from "./api"; import { api, ApiError } from "./api";
import { t } from "./i18n"; 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 blobshape from "blobshape"; import { t, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpServer, McpTransport, MemoryItem, Message, Room, RoomMember, Session } from "./types"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, ModelProviderId, Room, RoomMember, Session, WorkspaceSettings } from "./types";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,display:null,profileMode:"per-bot",screenAvailable:false}; const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,display:null,profileMode:"per-bot",screenAvailable:false};
const SESSION_STORE="lazyboy.sessionByBot"; const SESSION_STORE="lazyboy.sessionByBot";
const PANE_STORE="lazyboy.rightPane"; const PANE_STORE="lazyboy.rightPane";
const WORKSPACE_STORE="lazyboy.workspace"; const WORKSPACE_STORE="lazyboy.workspace";
type RightPart="computer"|"memory"|"settings"|"plugins"; type RightPart="computer"|"memory"|"settings"|"plugins";
type AccountDialog="phone"|"settings"|"about"|"help"|"feedback"|null; type AccountDialog="phone"|"settings"|"model"|"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 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 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:"computer"}}catch{return{collapsed:false,part:"computer"}}} 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:"computer"}}catch{return{collapsed:false,part:"computer"}}}
type WorkspacePrefs={name:string;showHidden:boolean}; 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 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}}}
const botColors=["#3ec5a8","#f5a03c","#6a6bf5","#9b5cf6","#3b82f6","#d9508a"];
function Avatar({name,color,shape="blob",active=false,thinking=false,online=false,size=32}:{name:string;color?:string;shape?:AvatarShape;active?:boolean;thinking?:boolean;online?:boolean;size?:number}){const hash=[...`${name}-${shape}`].reduce((n,c)=>(n*31+c.charCodeAt(0))>>>0,7);const resolved=color||botColors[hash%botColors.length];const generated=shape==="blob"||shape.startsWith("organic-");const edges=shape==="blob"?7:Number(shape.slice(8));const generatedPath=generated?blobshape({size:100,growth:shape==="blob"?8:6,edges,seed:hash||1}).path:null;const specialPath=shape==="cloud"?"M23 80C9 80 2 70 7 57C10 48 18 44 27 45C29 30 40 21 53 24C63 25 70 32 72 43C87 42 96 51 95 64C94 75 86 81 73 80C67 88 56 90 48 84C39 91 28 89 23 80Z":shape==="drop"?"M52 5C44 20 18 44 18 65C18 83 32 95 50 95C69 95 83 82 82 64C81 43 60 21 52 5Z":shape==="cat"?"M22 42C16 36 14 20 17 8C28 14 38 22 42 28C47 26.5 53 26.5 58 28C62 22 72 14 83 8C86 20 84 36 78 42C86 50 90 58 90 64C90 82 73 94 50 94C27 94 10 82 10 64C10 58 14 50 22 42Z":shape==="bunny"?"M38 34C32 30 28 18 30 6C40 8 46 20 47 32C49 31 51 31 53 32C54 20 60 8 70 6C72 18 68 30 62 34C76 42 84 54 84 66C84 84 69 94 50 94C31 94 16 84 16 66C16 54 24 42 38 34Z":shape==="star"?"M50 8L62 38L94 40L69 60L77 91L50 74L23 91L31 60L6 40L38 38Z":shape==="heart"?"M50 90C30 74 10 58 10 38C10 22 22 12 35 12C43 12 48 16 50 22C52 16 57 12 65 12C78 12 90 22 90 38C90 58 70 74 50 90Z":shape==="egg"?"M50 6C32 6 16 34 16 58C16 80 31 94 50 94C69 94 84 80 84 58C84 34 68 6 50 6Z":shape==="ghost"?"M50 6C29 6 15 24 15 46L15 82C15 86 18 89 22 89C26 89 28 85 32 85C36 85 38 89 42 89C46 89 48 85 52 85C56 85 58 89 62 89C66 89 68 85 72 85C76 85 78 89 82 89C86 89 89 86 89 82L89 46C89 24 75 6 50 6Z":shape==="sprout"?"M50 36C48 20 38 10 24 10C26 26 36 34 48 36C30 38 18 52 18 66C18 84 32 94 50 94C68 94 82 84 82 66C82 52 70 38 52 36C64 34 74 26 76 10C62 10 52 20 50 36Z":shape==="cactus"?"M50 10C38 10 28 18 28 30L28 58L20 58C15 58 12 61 12 66C12 71 15 74 20 74L28 74L28 78C28 89 38 96 50 96C62 96 72 89 72 78L72 64L80 64C85 64 88 61 88 56C88 51 85 48 80 48L72 48L72 30C72 18 62 10 50 10Z":shape==="mushroom"?"M50 12C28 12 12 28 12 46C12 51 15 54 21 54L38 54C37 59 38 64 38 68L38 82C38 90 42 94 50 94C58 94 62 90 62 82L62 68C62 64 63 59 62 54L79 54C85 54 88 51 88 46C88 28 72 12 50 12Z":shape==="paw"?"M8 40A9 9 0 1 0 26 40A9 9 0 1 0 8 40Z M24.5 27A10.5 10.5 0 1 0 45.5 27A10.5 10.5 0 1 0 24.5 27Z M54.5 27A10.5 10.5 0 1 0 75.5 27A10.5 10.5 0 1 0 54.5 27Z M74 40A9 9 0 1 0 92 40A9 9 0 1 0 74 40Z M20 66A29 22 0 1 0 78 66A29 22 0 1 0 20 66Z":null;const path=generatedPath||specialPath;const svgShape=Boolean(path);return <span className={`avatar robot avatar-${shape} ${svgShape?"avatar-organic":""} ${active?"online":""} ${thinking?"thinking":""}`} style={{"--bot-color":resolved,"--avatar-size":`${size}px`} as React.CSSProperties}>{path&&<svg className="avatar-shape" viewBox="0 0 100 100" aria-hidden="true"><path d={path}/></svg>}<span className="robot-eyes"><i/><i/></span>{(online||active)&&<i className="presence" aria-hidden="true"/>}</span>}
function AvatarStack({members,size=38,online=false,thinkingIds}:{members:RoomMember[];size?:number;online?:boolean;thinkingIds?:string[]}){
if(members.length===1){const member=members[0];return <Avatar name={member.name} color={member.avatarColor} shape={member.avatarShape} size={size} thinking={Boolean(thinkingIds?.includes(member.id))} online={online}/>}
const pair=members.length===2;
const miniSize=Math.round(size*(pair?.65:.54));
const shown=members.slice(0,members.length>3?2:3);
const positions=pair?[{left:0,top:0},{left:size-miniSize,top:size-miniSize}]:[{left:(size-miniSize)/2,top:0},{left:0,top:size-miniSize},{left:size-miniSize,top:size-miniSize}];
return <span className="avatar-stack" style={{width:size,height:size}}>
{shown.map((member,index)=><span className="stack-item" key={member.id} style={{...positions[index],zIndex:index+1}}><Avatar name={member.name} color={member.avatarColor} shape={member.avatarShape} size={miniSize} thinking={Boolean(thinkingIds?.includes(member.id))} online={Boolean(online&&index===shown.length-1)}/></span>)}
{members.length>3&&<span className="stack-extra" style={{left:size-miniSize,top:size-miniSize,zIndex:3,width:miniSize,height:miniSize}}>+{members.length-2}</span>}
</span>
}
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 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 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 stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
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();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 clientNonce(){
const webCrypto=globalThis.crypto;
if(webCrypto&&typeof webCrypto.randomUUID==="function")return webCrypto.randomUUID();
const bytes=new Uint8Array(16);
if(webCrypto&&typeof webCrypto.getRandomValues==="function")webCrypto.getRandomValues(bytes);
else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256);
bytes[6]=(bytes[6]&0x0f)|0x40;
bytes[8]=(bytes[8]&0x3f)|0x80;
const hex=[...bytes].map(b=>b.toString(16).padStart(2,"0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
export function App(){ export function App(){
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 [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[]>([]);
@ -70,7 +68,8 @@ export function App(){
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]); const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null); const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name); const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
const sendingRef=useRef(false); const [looks,setLooks]=useState(readAvatarLooks);
const sendingRef=useRef(false); const refreshingRef=useRef(false);
const roomsRef=useRef(rooms); const roomsRef=useRef(rooms);
roomsRef.current=rooms; roomsRef.current=rooms;
const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]); const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]);
@ -85,20 +84,22 @@ export function App(){
const sessionStoreKey=activeRoomId?`room:${activeRoomId}`:activeId; const sessionStoreKey=activeRoomId?`room:${activeRoomId}`:activeId;
const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null; const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null;
const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]); const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]);
const refresh=useCallback(async()=>{if(!activeSessionId)return;const messagesJob=api<Message[]>(`/api/sessions/${activeSessionId}/messages`).then(setMessages); const refresh=useCallback(async()=>{if(!activeSessionId||refreshingRef.current)return;refreshingRef.current=true;try{const messagesJob=api<Message[]>(`/api/sessions/${activeSessionId}/messages`).then(setMessages);
let computerBot=activeId; let computerBot=activeId;
if(activeRoomId){ if(activeRoomId){
try{const status=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);setBusyMembers(status.busy);computerBot=status.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{setBusyMembers([]);computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null} try{const status=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);setBusyMembers(status.busy);computerBot=status.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{setBusyMembers([]);computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}
}else setBusyMembers([]); }else setBusyMembers([]);
if(!computerBot){await messagesJob;return} if(!computerBot){await messagesJob;return}
await Promise.all([messagesJob,api<ComputerStatus>(`/api/computer/${computerBot}/status`).then(setComputer),api<{url:string|null}>(`/api/computer/${computerBot}/screen`).then(screen=>setScreenUrl(screen.url)).catch(()=>setScreenUrl(null))])},[activeId,activeRoomId,activeSessionId]); const status=await api<ComputerStatus>(`/api/computer/${computerBot}/status`);setComputer(status);await messagesJob;
if(status.state==="running")await api<{url:string|null}>(`/api/computer/${computerBot}/screen`).then(screen=>setScreenUrl(screen.url)).catch(()=>setScreenUrl(null));else setScreenUrl(null)
}finally{refreshingRef.current=false}},[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(e.message)})},[loadBots]);
useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]); 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(e.message))},[loadSessions]);
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){setMessages([]);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}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)},[activeId,activeRoomId,activeSessionId,refresh]); useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){setMessages([]);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}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)},[activeId,activeRoomId,activeSessionId,refresh]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)}); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||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 listener=(event:MessageEvent)=>{if(event.origin!==location.origin||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=()=>{setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(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);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]); useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]); 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(()=>{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]);
@ -111,7 +112,7 @@ export function App(){
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?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 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 send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();if((!active&&!activeRoom)||!activeSessionId||!text)return;sendingRef.current=true;setDraft("");try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:crypto.randomUUID()})});await loadSessions()})}finally{sendingRef.current=false}} async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();if((!active&&!activeRoom)||!activeSessionId||!text)return;sendingRef.current=true;setDraft("");try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce()})});await loadSessions()})}finally{sendingRef.current=false}}
function selectSession(id:string){setActiveSessionId(id);setSessionMenuOpen(false);if(sessionStoreKey)writeSessionStore(sessionStoreKey,id)} 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?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 clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})}
@ -125,7 +126,11 @@ export function App(){
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()})} 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)} 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 logout(){setAccountOpen(false);await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setRooms([]);setMcpServers([]);setActiveId(null);setActiveRoomId(null);setAuthRequired(true)}
const frame=screenUrl?<iframe className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>; // Re-mount noVNC after every status transition so a freshly booted Docker
// display cannot remain stuck on the previous disconnected iframe.
const startBoot=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/boot`,{method:"POST",body:"{}"})};
const restartComputer=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/restart`,{method:"POST",body:"{}"})};
const frame=screenUrl?<iframe key={`${screenUrl}:${computer.state}:${computer.botId}:${computer.controlHolder}`} className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
const topTools=<nav className="top-tools" aria-label={t("workTools")}> const topTools=<nav className="top-tools" aria-label={t("workTools")}>
<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==="computer"?"active":""}`} title={t("computer")} aria-label={t("computer")} onClick={()=>openPane("computer")}><Computer/></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> <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>
@ -135,13 +140,13 @@ export function App(){
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?e.message:t("loginFailed"))}}}/>;
return <div className={`app-shell ${rightCollapsed?"right-collapsed":"right-open"}`}> return <AvatarLookProvider value={looks}><div className={`app-shell ${rightCollapsed?"right-collapsed":"right-open"}`}>
<aside className={`sidebar ${mobileNav?"open":""}`}> <aside className={`sidebar ${mobileNav?"open":""}`}>
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap"><button className="icon-button" onClick={()=>setCreateMenuOpen(v=>!v)} aria-label={t("add")}><UseAnimations animation={plusToX} size={18} strokeColor="#dfdfe2"/></button>{createMenuOpen&&<div className="create-menu"><button onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/>{t("addBot")}</button><button 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="#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>
<label className="search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("search")}/></label> <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"> <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>} {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>}
{sections.map(([label,items])=><section className="bot-group" key={label}><div className="group-label">{label}</div>{items.map(bot=><button className={`bot-row ${bot.id===activeId&&!activeRoomId?"selected":""}`} key={bot.id} onClick={()=>openBot(bot)} onContextMenu={e=>{e.preventDefault();setContext({bot,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><Avatar name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} active={bot.id===activeId&&!activeRoomId} online/>{bot.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count:bot.unreadCount})}/>}</span><span className="bot-copy"><strong>{bot.name}</strong><small>{modeLabel(bot.computerMode)}</small></span>{bot.tags?.[0]&&<span className="bot-tag side-tag">{bot.tags[0]}</span>}{bot.lastMessageAt&&<time className="row-time">{inboxTime(bot.lastMessageAt)}</time>}{bot.pinned&&<Pin className="row-pin"/>}</button>)}</section>)} {sections.map(([label,items])=><section className="bot-group" key={label}><div className="group-label">{label}</div>{items.map(bot=><button className={`bot-row ${bot.id===activeId&&!activeRoomId?"selected":""}`} key={bot.id} onClick={()=>openBot(bot)} onContextMenu={e=>{e.preventDefault();setContext({bot,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} active={bot.id===activeId&&!activeRoomId} online/>{bot.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count:bot.unreadCount})}/>}</span><span className="bot-copy"><strong>{bot.name}</strong><small>{modeLabel(bot.computerMode)}</small></span>{bot.tags?.[0]&&<span className="bot-tag side-tag">{bot.tags[0]}</span>}{bot.lastMessageAt&&<time className="row-time">{inboxTime(bot.lastMessageAt)}</time>}{bot.pinned&&<Pin className="row-pin"/>}</button>)}</section>)}
</div> </div>
<button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<UseAnimations animation={visibility2} size={14} strokeColor="var(--muted)"/>:<UseAnimations animation={visibility} size={14} strokeColor="var(--muted)"/>}{showHidden?t("hideHiddenItems"):t("showHiddenItems")}</button> <button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<UseAnimations animation={visibility2} size={14} strokeColor="var(--muted)"/>:<UseAnimations animation={visibility} size={14} strokeColor="var(--muted)"/>}{showHidden?t("hideHiddenItems"):t("showHiddenItems")}</button>
<div className="sidebar-bottom"> <div className="sidebar-bottom">
@ -153,6 +158,7 @@ export function App(){
{accountOpen&&<div className="account-menu" role="menu"> {accountOpen&&<div className="account-menu" role="menu">
<button type="button" role="menuitem" onClick={()=>openAccount("phone")}><Smartphone/>{t("openOnPhone")}</button> <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("settings")}><Settings/>{t("settings")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("model")}><BotIcon/>{t("modelSettings")}</button>
<button type="button" role="menuitem" disabled={!paneBot} onClick={()=>{setAccountOpen(false);openPane("memory")}}><Brain/>{t("memory")}</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("about")}><Info/>{t("about")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("help")}><CircleHelp/>{t("helpCenter")}</button> <button type="button" role="menuitem" onClick={()=>openAccount("help")}><CircleHelp/>{t("helpCenter")}</button>
@ -168,8 +174,8 @@ export function App(){
</aside> </aside>
<main className="chat-panel"> <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} 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 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> <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} 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 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;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{spoken&&<span className="msg-avatar"><Avatar name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}<span className="message-body">{message.body}</span>{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>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{t("working",{name:member.name})}</span></div>)}</div> <div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{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>}<span className="message-body">{message.body}</span>{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>})}{workingMembers.map(member=><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-label">{t("working",{name:member.name})}</span></div>)}</div>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>} {error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>} {otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
<form className="composer" onSubmit={send}><button type="button" className="composer-plus" disabled title={t("attachmentsUnavailable")} aria-label={t("attachmentsUnavailable")}><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={e=>{if(e.nativeEvent.isComposing||e.key==="Process")return;if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(sendingRef.current||busy)return;e.currentTarget.form?.requestSubmit()}}} placeholder={activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form> <form className="composer" onSubmit={send}><button type="button" className="composer-plus" disabled title={t("attachmentsUnavailable")} aria-label={t("attachmentsUnavailable")}><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={e=>{if(e.nativeEvent.isComposing||e.key==="Process")return;if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(sendingRef.current||busy)return;e.currentTarget.form?.requestSubmit()}}} placeholder={activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
@ -186,16 +192,16 @@ export function App(){
<div className={`side-part computer-part ${rightPart==="computer"?"":"hidden-part"}`}> <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="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}</div> <div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}</div>
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div><ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId}/></>} {paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div><ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/></>}
</div> </div>
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>} {rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>} {rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>}
{rightPart==="settings"&&active&&<BotSettingsPane bot={active} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>} {rightPart==="settings"&&active&&<BotSettingsPane bot={active} look={looks[active.id]||DEFAULT_LOOK} onLook={look=>{writeAvatarLook(active.id,look);setLooks(readAvatarLooks())}} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>}
</div> </div>
</> </>
</aside>} </aside>}
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className="control-badge">{computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><div><ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId}/><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">{frame}</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">{computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><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">{frame}</div>{error&&<div className="overlay-error">{error}</div>}</div>}
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin));setClipboardOpen(false)}}/>} {clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin));setClipboardOpen(false)}}/>}
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>} {createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
@ -208,10 +214,11 @@ export function App(){
{roomContext&&<RoomContextMenu context={roomContext} close={()=>setRoomContext(null)} onDelete={()=>{setRoomToDelete(roomContext.room);setRoomContext(null)}}/>} {roomContext&&<RoomContextMenu context={roomContext} close={()=>setRoomContext(null)} onDelete={()=>{setRoomToDelete(roomContext.room);setRoomContext(null)}}/>}
{accountDialog==="phone"&&<PhoneAccessDialog close={()=>setAccountDialog(null)}/>} {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==="settings"&&<WorkspaceSettingsDialog name={workspaceName} setName={setWorkspaceName} showHidden={showHidden} setShowHidden={setShowHidden} rightCollapsed={rightCollapsed} setRightCollapsed={setRightCollapsed} close={()=>setAccountDialog(null)}/>}
{accountDialog==="model"&&<ModelSettingsDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="about"&&<AboutDialog close={()=>setAccountDialog(null)}/>} {accountDialog==="about"&&<AboutDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="help"&&<HelpDialog close={()=>setAccountDialog(null)}/>} {accountDialog==="help"&&<HelpDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="feedback"&&<FeedbackDialog close={()=>setAccountDialog(null)}/>} {accountDialog==="feedback"&&<FeedbackDialog close={()=>setAccountDialog(null)}/>}
</div> </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="#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>}
@ -247,15 +254,20 @@ function ConfirmSessionDelete({close,confirm}:{close:()=>void;confirm:()=>void})
return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteConversationTitle")}</h2><p>{t("deleteConversationDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteConversation")}</button></div></div></div> return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteConversationTitle")}</h2><p>{t("deleteConversationDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteConversation")}</button></div></div></div>
} }
function BotSettingsPane({bot,saved,onDelete}:{bot:Bot;saved:()=>Promise<void>|void;onDelete:()=>void}){ const EXPR_I18N = {idle:"exprIdle",happy:"exprHappy",sad:"exprSad",mad:"exprMad",surprised:"exprSurprised",wink:"exprWink",sleepy:"exprSleepy",smug:"exprSmug",unsure:"exprUnsure",scared:"exprScared",love:"exprLove",shy:"exprShy",sick:"exprSick",thinking:"exprThinking"} as const satisfies Record<AvatarExpression, MessageKey>;
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>(bot.avatarShape||"blob");const[tagText,setTagText]=useState((bot.tags||[]).join("、"));const[busy,setBusy]=useState(false); const BG_I18N = {none:"bgNone",circle:"bgCircle",squircle:"bgSquircle",square:"bgSquare"} as const satisfies Record<AvatarBackground, MessageKey>;
useEffect(()=>{setName(bot.name);setTitle(bot.title);setDescription(bot.description);setColor(bot.avatarColor||"#8B5CF6");setShape(bot.avatarShape||"blob");setTagText((bot.tags||[]).join("、"))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags]);
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 colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"]; const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];
const shapes:AvatarShape[]=["round","blob","squircle","capsule","triangle","hexagon","cloud","drop","cat","bunny","star","heart","egg","ghost","sprout","cactus","mushroom","paw"]; 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:shape,tags:tagText.split(/[、,]/).map(v=>v.trim()).filter(Boolean)})});await saved()}finally{setBusy(false)}}}> 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)}}}>
<div className="avatar-editor"><Avatar name={name||bot.name} color={color} shape={shape} size={78}/><strong>{t("avatarAppearance")}</strong><small>{t("avatarAppearanceHint")}</small></div> <div className="avatar-editor"><Avatar {...face} size={96}/><strong>{t("avatarAppearance")}</strong><small>{t("avatarAppearanceHint")}</small></div>
<fieldset><legend>{t("color")}</legend><div className="color-grid">{colors.map(value=><button type="button" key={value} className={color===value?"selected":""} style={{background:value}} onClick={()=>setColor(value)} aria-label={t("chooseColor",{value})}/>)}<label className="custom-color" title={t("customColor")}><input type="color" value={color} onChange={e=>setColor(e.target.value.toUpperCase())}/><span></span></label></div></fieldset> <fieldset><legend>{t("color")}</legend><div className="color-grid">{colors.map(value=><button type="button" key={value} className={color===value?"selected":""} style={{background:value}} onClick={()=>setColor(value)} aria-label={t("chooseColor",{value})}/>)}<label className="custom-color" title={t("customColor")}><input type="color" value={color} onChange={e=>setColor(e.target.value.toUpperCase())}/><span></span></label></div></fieldset>
<fieldset><legend>{t("shape")}</legend><div className="shape-grid">{shapes.map(value=><button type="button" className={shape===value?"selected":""} onClick={()=>setShape(value)} key={value}><Avatar name={name||bot.name} color={color} shape={value}/></button>)}</div></fieldset> <fieldset><legend>{t("shape")}</legend><div className="shape-grid">{BLOBATAR_SHAPES.map(value=><button type="button" className={resolveBlobatarShape(shape)===value?"selected":""} onClick={()=>setShape(value)} key={value}><Avatar {...face} shape={value} gaze={false}/></button>)}</div></fieldset>
<fieldset><legend>{t("expression")}</legend><div className="expr-grid">{BLOBATAR_EXPRESSIONS.map(value=><button type="button" className={expression===value?"selected":""} onClick={()=>setExpression(value)} key={value}><Avatar {...face} expression={value} gaze={false} size={36}/><span>{t(EXPR_I18N[value])}</span></button>)}</div></fieldset>
<fieldset><legend>{t("background")}</legend><div className="bg-grid">{BLOBATAR_BACKGROUNDS.map(value=><button type="button" className={background===value?"selected":""} onClick={()=>setBackground(value)} key={value}><Avatar {...face} background={value} gaze={false} size={36}/><span>{t(BG_I18N[value])}</span></button>)}</div></fieldset>
<label>{t("name")}<input value={name} maxLength={80} onChange={e=>setName(e.target.value)}/></label> <label>{t("name")}<input value={name} maxLength={80} onChange={e=>setName(e.target.value)}/></label>
<label>{t("tags")}<input value={tagText} maxLength={120} onChange={e=>setTagText(e.target.value)} placeholder={t("tagsPlaceholder")}/><small>{t("tagsLimit")}</small></label> <label>{t("tags")}<input value={tagText} maxLength={120} onChange={e=>setTagText(e.target.value)} placeholder={t("tagsPlaceholder")}/><small>{t("tagsLimit")}</small></label>
<label>{t("shortTitle")}<input value={title} maxLength={100} onChange={e=>setTitle(e.target.value)} placeholder={t("shortTitlePlaceholder")}/></label> <label>{t("shortTitle")}<input value={title} maxLength={100} onChange={e=>setTitle(e.target.value)} placeholder={t("shortTitlePlaceholder")}/></label>
@ -302,26 +314,12 @@ 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 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>}){ function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}){
const[name,setName]=useState("");const[transport,setTransport]=useState<McpTransport>("http"); const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null);
const[command,setCommand]=useState("");const[args,setArgs]=useState("");const[url,setUrl]=useState("");
const[envText,setEnvText]=useState("");const[headerText,setHeaderText]=useState("");
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?e.message:t("operationFailed"))}finally{setBusy(false)}}
return <div className="mcp-pane"> return <div className="mcp-pane">
<p className="memory-help">{t("mcpHelp")}</p> <p className="memory-help">{t("mcpHelp")}</p>
<form className="mcp-add" onSubmit={e=>{e.preventDefault();const trimmed=name.trim();if(!trimmed)return;void run(async()=>{await api("/api/mcp-servers",{method:"POST",body:JSON.stringify({name:trimmed,transport,command:command.trim()||null,args:args.trim()?args.trim().split(/\s+/):[],url:url.trim()||null,env:parsePairs(envText),headers:parsePairs(headerText),enabled:true})});setName("");setCommand("");setArgs("");setUrl("");setEnvText("");setHeaderText("")})}}> <button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button>
<label>{t("name")}<input value={name} maxLength={40} onChange={e=>setName(e.target.value)} placeholder={t("mcpNamePlaceholder")}/></label> {picker&&<McpPickerDialog added={servers.map(server=>server.name)} close={()=>setPicker(false)} connected={async()=>{setPicker(false);await reload()}}/>}
<div className="mcp-transport">{(["http","sse","stdio"] as McpTransport[]).map(value=><button type="button" key={value} className={transport===value?"picked":""} onClick={()=>setTransport(value)}>{value==="http"?"HTTP":value==="sse"?"SSE":"stdio"}</button>)}</div>
{transport==="stdio"?<>
<label>{t("command")}<input value={command} onChange={e=>setCommand(e.target.value)} placeholder={t("commandPlaceholder")}/></label>
<label>{t("arguments")}<input value={args} onChange={e=>setArgs(e.target.value)} placeholder="-y @modelcontextprotocol/server-github"/></label>
<label>{t("environmentVariables")}<textarea rows={3} value={envText} onChange={e=>setEnvText(e.target.value)} placeholder="GITHUB_TOKEN=…"/></label>
</>:<>
<label>{t("url")}<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="https://mcp.example.com/mcp"/></label>
<label>{t("headers")}<textarea rows={3} value={headerText} onChange={e=>setHeaderText(e.target.value)} placeholder="Authorization=Bearer …"/></label>
</>}
<button className="primary" disabled={busy||!name.trim()||(transport==="stdio"?!command.trim():!url.trim())}>{busy?t("connecting"):t("connectMcp")}</button>
</form>
<div className="mcp-list">{servers.length===0?<p>{t("noMcp")}</p>:servers.map(server=><div className={`mcp-card ${server.status}`} key={server.id}> <div className="mcp-list">{servers.length===0?<p>{t("noMcp")}</p>:servers.map(server=><div className={`mcp-card ${server.status}`} key={server.id}>
<button type="button" className="mcp-card-head" onClick={()=>setOpenId(id=>id===server.id?null:server.id)}> <button type="button" className="mcp-card-head" onClick={()=>setOpenId(id=>id===server.id?null:server.id)}>
<i className={`mcp-dot ${server.status}`}/><strong>{server.name}</strong> <i className={`mcp-dot ${server.status}`}/><strong>{server.name}</strong>
@ -341,12 +339,83 @@ function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}
</div> </div>
} }
function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;connected:()=>Promise<void>}){
const[query,setQuery]=useState("");const[custom,setCustom]=useState(false);
const[items,setItems]=useState<McpCatalogEntry[]>([]);const[loading,setLoading]=useState(true);
const[picked,setPicked]=useState<McpCatalogEntry|null>(null);
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]);
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>={};
for(const field of entry.envKeys){const value=values[field.name]?.trim();if(value)env[field.name]=value;else if(field.required){setError(t("mcpKeyHint"));return}}
for(const field of entry.headerKeys){const value=values[field.name]?.trim();if(value)headers[field.name]=value;else if(field.required){setError(t("mcpKeyHint"));return}}
setBusy(true);setError("");
try{
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"))}
finally{setBusy(false)}
}
function choose(entry:McpCatalogEntry){
if(addedSet.has(entry.title.toLowerCase())||addedSet.has(entry.id.toLowerCase()))return;
if(needsSecrets(entry)){setPicked(entry);setSecrets({});return}
void connect(entry,{});
}
return <div className="modal-backdrop" onClick={close}><div className="dialog mcp-picker" onClick={e=>e.stopPropagation()}>
<div className="dialog-title"><h2>{t("mcpPickerTitle")}</h2><button type="button" onClick={close}><X/></button></div>
{picked?<div className="mcp-secret-step">
<button type="button" className="outline mcp-back" onClick={()=>setPicked(null)}>{t("mcpBackToList")}</button>
<strong>{t("mcpConnectNamed",{name:picked.title})}</strong>
<p className="dialog-lead">{t("mcpKeyHint")}</p>
{[...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)}}}/>:<>
<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)}>
<span className="mcp-pick-icon">{entry.title.slice(0,1).toUpperCase()}</span>
<strong>{entry.title}</strong>
<small>{entry.description}</small>
<span className="mcp-pick-meta">{already?t("mcpAdded"):entry.remote?t("mcpRemote"):t("mcpLocal")}{needsSecrets(entry)&&!already?` · ${t("mcpNeedsKey")}`:""}</span>
</button>})}</div>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={()=>setCustom(true)}>{t("mcpCustom")}</button><button type="button" className="outline" onClick={close}>{t("close")}</button></div>
</>}
</div></div>
}
function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;onBack:()=>void;onSubmit:(payload:Record<string,unknown>)=>Promise<void>}){
const[name,setName]=useState("");const[transport,setTransport]=useState<McpTransport>("http");
const[command,setCommand]=useState("");const[args,setArgs]=useState("");const[url,setUrl]=useState("");
const[envText,setEnvText]=useState("");const[headerText,setHeaderText]=useState("");
return <form className="mcp-add" onSubmit={e=>{e.preventDefault();const trimmed=name.trim();if(!trimmed)return;void onSubmit({name:trimmed,transport,command:command.trim()||null,args:args.trim()?args.trim().split(/\s+/):[],url:url.trim()||null,env:parsePairs(envText),headers:parsePairs(headerText),enabled:true})}}>
<button type="button" className="outline mcp-back" onClick={onBack}>{t("mcpBackToList")}</button>
<label>{t("name")}<input autoFocus value={name} maxLength={80} onChange={e=>setName(e.target.value)} placeholder={t("mcpNamePlaceholder")}/></label>
<div className="mcp-transport">{(["http","sse","stdio"] as McpTransport[]).map(value=><button type="button" key={value} className={transport===value?"picked":""} onClick={()=>setTransport(value)}>{value==="http"?"HTTP":value==="sse"?"SSE":"stdio"}</button>)}</div>
{transport==="stdio"?<>
<label>{t("command")}<input value={command} onChange={e=>setCommand(e.target.value)} placeholder={t("commandPlaceholder")}/></label>
<label>{t("arguments")}<input value={args} onChange={e=>setArgs(e.target.value)} placeholder={t("argumentsPlaceholder")}/></label>
<label>{t("environmentVariables")}<textarea rows={3} value={envText} onChange={e=>setEnvText(e.target.value)} placeholder={t("environmentVariablesPlaceholder")}/></label>
</>:<>
<label>{t("url")}<input value={url} onChange={e=>setUrl(e.target.value)} placeholder={t("urlPlaceholder")}/></label>
<label>{t("headers")}<textarea rows={3} value={headerText} onChange={e=>setHeaderText(e.target.value)} placeholder={t("headersPlaceholder")}/></label>
</>}
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={onBack}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()||(transport==="stdio"?!command.trim():!url.trim())}>{busy?t("connecting"):t("connectMcp")}</button></div>
</form>
}
function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<UseAnimations animation={loading2} size={38} wrapperStyle={{display:"block"}}/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?t("preparingDesktop"):t("computerPreviewHint")}</span></div>} function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<UseAnimations animation={loading2} size={38} wrapperStyle={{display:"block"}}/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?t("preparingDesktop"):t("computerPreviewHint")}</span></div>}
function ControlButtons({computer,busy,action,active,sessionId}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null}){const working=Boolean(computer.busyBotName);if(computer.state!=="running")return <button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>action(()=>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>;if(working)return <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:"{}"});if(computer.takeoverRequested)await api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"})})}><Square/>{computer.takeoverRequested?t("stopAndTakeOver"):t("stopTask")}</button>;if(computer.controlHolder==="user")return <button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>;return <button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>} 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.busyBotName);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}){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 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 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 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 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 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 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="#ff7777"/>{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 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>} 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>}
@ -373,6 +442,64 @@ function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCol
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div> <div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div>
</form></div> </form></div>
} }
function ModelSettingsDialog({close}:{close:()=>void}){
const[settings,setSettings]=useState<WorkspaceSettings|null>(null);
const[provider,setProvider]=useState<ModelProviderId>("xai");
const[modelId,setModelId]=useState("");
const[baseUrl,setBaseUrl]=useState("");
const[apiKey,setApiKey]=useState("");
const[clearKey,setClearKey]=useState(false);
const[models,setModels]=useState<{id:string;name:string}[]>([]);
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")))},[]);
async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
try{
const query=new URLSearchParams({provider:nextProvider});
if(nextBaseUrl.trim())query.set("baseUrl",nextBaseUrl.trim());
const result=await api<{models:{id:string;name:string}[]}>(`/api/workspace/models?${query}`);
setModels(result.models);
setModelId(current=>result.models.some(item=>item.id===current)?current:result.models[0]?.id||current);
}catch{if(nextProvider==="openai-compatible")setModels([])}
}
function pickProvider(id:ModelProviderId){
setProvider(id);
const info=settings?.providers.find(item=>item.id===id);
if(id==="openai-compatible"){
setModels([]);
setBaseUrl(current=>current.includes("opencode.ai")||current.includes("api.x.ai")?"":current);
if(info?.defaultModel)setModelId(info.defaultModel);else setModelId("");
return;
}
if(info?.defaultBaseUrl)setBaseUrl(info.defaultBaseUrl);
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)}}}>
<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>
<div className="provider-grid">{(settings?.providers||[{id:"xai" as const,name:t("providerXai")},{id:"opencode-go" as const,name:t("providerOpencodeGo")},{id:"openai-compatible" as const,name:t("providerOpenaiCompatible")}]).map(item=><button type="button" key={item.id} className={provider===item.id?"picked":""} onClick={()=>pickProvider(item.id)}>{item.id==="xai"?t("providerXai"):item.id==="opencode-go"?t("providerOpencodeGo"):t("providerOpenaiCompatible")}</button>)}</div>
<p className="dialog-lead">{provider==="xai"?t("providerXaiHint"):provider==="opencode-go"?t("providerOpencodeGoHint"):t("providerOpenaiCompatibleHint")}</p>
</fieldset>
<label>{t("apiKey")}<input type="password" autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);setClearKey(false)}} placeholder={settings?.apiKeySet?t("apiKeyStored"):t("apiKeyPlaceholder")}/></label>
{settings?.envKeySet&&settings.provider===provider&&!apiKey&&!clearKey&&<p className="dialog-lead">{t("usingEnvKey",{name:settings.envKeyName})}</p>}
{settings?.apiKeySet&&<label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={e=>setClearKey(e.target.checked)}/> {t("clearApiKey")}</label>}
{(current?.needsBaseUrl||provider==="openai-compatible")&&<label>{t("baseUrl")}<input value={baseUrl} onChange={e=>setBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")}/><small>{t("baseUrlHint")}</small></label>}
<label>{t("modelId")}
{models.length>0?<select value={models.some(item=>item.id===modelId)?modelId:""} onChange={e=>setModelId(e.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={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
{models.length>0&&<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
</label>
<button type="button" className="outline" onClick={()=>void loadModels(provider,baseUrl)}>{t("reloadModels")}</button>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!modelId.trim()||(provider==="openai-compatible"&&!baseUrl.trim())}>{busy?t("saving"):t("save")}</button></div>
</form></div>
}
function AboutDialog({close}:{close:()=>void}){ function AboutDialog({close}:{close:()=>void}){
const[health,setHealth]=useState<"ok"|"bad"|"…">("…"); const[health,setHealth]=useState<"ok"|"bad"|"…">("…");
useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]); useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]);

319
apps/web/src/avatar.tsx Normal file
View File

@ -0,0 +1,319 @@
import { Blobatar } from "@blobatar/react";
import { useGaze } from "@blobatar/react/gaze";
import {
happy,
idle,
love,
mad,
sad,
scared,
shy,
sick,
sleepy,
smug,
surprised,
thinking as thinkingPose,
unsure,
wink,
type Expression,
} from "blobatar/expression";
import { createContext, useContext, type CSSProperties, type ReactNode } from "react";
import type { AvatarShape, RoomMember } from "./types";
export const BLOBATAR_SHAPES = [
"round",
"organic",
"boxy",
"capsule",
"nub",
"cloud",
"droplet",
"hexagon",
"sun",
"triangle",
] as const;
export type BlobatarShape = (typeof BLOBATAR_SHAPES)[number];
export const BLOBATAR_EXPRESSIONS = [
"idle",
"happy",
"sad",
"mad",
"surprised",
"wink",
"sleepy",
"smug",
"unsure",
"scared",
"love",
"shy",
"sick",
"thinking",
] as const;
export type AvatarExpression = (typeof BLOBATAR_EXPRESSIONS)[number];
export const BLOBATAR_BACKGROUNDS = ["none", "circle", "squircle", "square"] as const;
export type AvatarBackground = (typeof BLOBATAR_BACKGROUNDS)[number];
export interface AvatarLook {
expression: AvatarExpression;
background: AvatarBackground;
}
export const DEFAULT_LOOK: AvatarLook = { expression: "idle", background: "none" };
const EXPRESSION_POSE: Record<AvatarExpression, Expression> = {
idle,
happy,
sad,
mad,
surprised,
wink,
sleepy,
smug,
unsure,
scared,
love,
shy,
sick,
thinking: thinkingPose,
};
/** Midpoints of blobatar gen2 shape bands in styles/blob.ts. */
const SHAPE_TRAIT: Record<BlobatarShape, number> = {
round: 0.11,
organic: 0.35,
boxy: 0.54,
capsule: 0.65,
nub: 0.745,
cloud: 0.825,
droplet: 0.887,
hexagon: 0.932,
sun: 0.965,
triangle: 0.99,
};
const SHAPE_ALIAS: Record<string, BlobatarShape> = {
blob: "organic",
squircle: "boxy",
diamond: "boxy",
drop: "droplet",
cat: "nub",
bunny: "nub",
star: "sun",
heart: "organic",
egg: "round",
ghost: "cloud",
sprout: "nub",
cactus: "nub",
mushroom: "nub",
paw: "nub",
};
const FALLBACK_COLORS = ["#3ec5a8", "#f5a03c", "#6a6bf5", "#9b5cf6", "#3b82f6", "#d9508a"];
const LOOK_STORE = "lazyboy.avatarLook";
const AvatarLookContext = createContext<Record<string, AvatarLook>>({});
export function AvatarLookProvider({
value,
children,
}: {
value: Record<string, AvatarLook>;
children: ReactNode;
}) {
return <AvatarLookContext.Provider value={value}>{children}</AvatarLookContext.Provider>;
}
export function readAvatarLooks(): Record<string, AvatarLook> {
try {
const raw = localStorage.getItem(LOOK_STORE);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, Partial<AvatarLook>>;
const out: Record<string, AvatarLook> = {};
for (const [id, look] of Object.entries(parsed || {})) {
out[id] = {
expression: isExpression(look.expression) ? look.expression : "idle",
background: isBackground(look.background) ? look.background : "none",
};
}
return out;
} catch {
return {};
}
}
export function writeAvatarLook(id: string, look: AvatarLook) {
const next = { ...readAvatarLooks(), [id]: look };
localStorage.setItem(LOOK_STORE, JSON.stringify(next));
}
export function resolveBlobatarShape(shape: string | undefined | null): BlobatarShape {
if (shape && (BLOBATAR_SHAPES as readonly string[]).includes(shape)) return shape as BlobatarShape;
if (shape && SHAPE_ALIAS[shape]) return SHAPE_ALIAS[shape];
if (shape?.startsWith("organic-")) return "organic";
return "organic";
}
/** Persist using names the existing API already accepts. */
const STORE_SHAPE: Record<BlobatarShape, AvatarShape> = {
round: "round",
organic: "blob",
boxy: "squircle",
capsule: "capsule",
nub: "paw",
cloud: "cloud",
droplet: "drop",
hexagon: "hexagon",
sun: "star",
triangle: "triangle",
};
export function persistBlobatarShape(shape: string | undefined | null): AvatarShape {
return STORE_SHAPE[resolveBlobatarShape(shape)];
}
function isExpression(value: unknown): value is AvatarExpression {
return typeof value === "string" && (BLOBATAR_EXPRESSIONS as readonly string[]).includes(value);
}
function isBackground(value: unknown): value is AvatarBackground {
return typeof value === "string" && (BLOBATAR_BACKGROUNDS as readonly string[]).includes(value);
}
function fallbackColor(name: string): string {
const hash = [...name].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7);
return FALLBACK_COLORS[hash % FALLBACK_COLORS.length];
}
/** Dark eyes on light bodies, light eyes on dark ones — same polarity blobatar uses. */
export function contrastEye(hex: string): string {
const value = /^#([0-9a-f]{6})$/i.test(hex) ? hex : "#8B5CF6";
const n = parseInt(value.slice(1), 16);
const channel = (shift: number) => {
const s = ((n >> shift) & 255) / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const luminance = 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0);
return luminance >= 0.5 ? "#1b1b22" : "#f3f3f6";
}
function blobBackground(value: AvatarBackground | undefined): false | "circle" | "squircle" | "square" {
return !value || value === "none" ? false : value;
}
export function Avatar({
name,
color,
shape = "blob",
active = false,
thinking = false,
online = false,
size = 32,
lookId,
expression,
background,
gaze = true,
}: {
name: string;
color?: string;
shape?: AvatarShape;
active?: boolean;
thinking?: boolean;
online?: boolean;
size?: number;
lookId?: string;
expression?: AvatarExpression;
background?: AvatarBackground;
gaze?: boolean;
}) {
const stored = useContext(AvatarLookContext)[lookId || ""] || DEFAULT_LOOK;
const resolved = color || fallbackColor(name);
const silhouette = resolveBlobatarShape(shape);
const poseName = thinking ? "thinking" : expression || stored.expression;
const pose = poseName === "idle" ? undefined : EXPRESSION_POSE[poseName];
const plate = blobBackground(background || stored.background);
const travel = Math.max(1.8, Math.min(4, size * 0.045));
const { ref } = useGaze({ travel, lookAt: gaze ? "pointer" : null });
return (
<span
className={`avatar blobatar ${active ? "online" : ""} ${thinking ? "thinking" : ""}`}
style={{ "--bot-color": resolved, "--avatar-size": `${size}px` } as CSSProperties}
>
<Blobatar
ref={ref}
name={name}
animate="always"
expression={pose}
background={plate}
traits={{ shape: SHAPE_TRAIT[silhouette] }}
palette={{ head: resolved, eye: contrastEye(resolved) }}
title={name}
/>
{(online || active) && <i className="presence" aria-hidden="true" />}
</span>
);
}
export function AvatarStack({
members,
size = 38,
online = false,
thinkingIds,
}: {
members: RoomMember[];
size?: number;
online?: boolean;
thinkingIds?: string[];
}) {
if (members.length === 1) {
const member = members[0];
return (
<Avatar
lookId={member.id}
name={member.name}
color={member.avatarColor}
shape={member.avatarShape}
size={size}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={online}
/>
);
}
const pair = members.length === 2;
const miniSize = Math.round(size * (pair ? 0.65 : 0.54));
const shown = members.slice(0, members.length > 3 ? 2 : 3);
const positions = pair
? [{ left: 0, top: 0 }, { left: size - miniSize, top: size - miniSize }]
: [
{ left: (size - miniSize) / 2, top: 0 },
{ left: 0, top: size - miniSize },
{ left: size - miniSize, top: size - miniSize },
];
return (
<span className="avatar-stack" style={{ width: size, height: size }}>
{shown.map((member, index) => (
<span className="stack-item" key={member.id} style={{ ...positions[index], zIndex: index + 1 }}>
<Avatar
lookId={member.id}
name={member.name}
color={member.avatarColor}
shape={member.avatarShape}
size={miniSize}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={Boolean(online && index === shown.length - 1)}
/>
</span>
))}
{members.length > 3 && (
<span
className="stack-extra"
style={{ left: size - miniSize, top: size - miniSize, zIndex: 3, width: miniSize, height: miniSize }}
>
+{members.length - 2}
</span>
)}
</span>
);
}

View File

@ -1,3 +0,0 @@
declare module "blobshape" {
export default function blobshape(options?: { size?:number; growth?:number; edges?:number; seed?:number }): { path:string; seedValue:number };
}

View File

@ -2,7 +2,7 @@
export const zhTW = { export const zhTW = {
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦", search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤", stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤",
openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", releaseControl: "釋放控制", done: "完成", skip: "略過", openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", takeOverNow: "接手操作", releaseControl: "釋放控制", done: "完成", skip: "略過",
localWorkspace: "本機工作區", pinned: "已釘選", agentGroup: "Agent", localWorkspace: "本機工作區", pinned: "已釘選", agentGroup: "Agent",
operationFailed: "操作失敗", loadFailed: "載入失敗", settingsFailed: "設定失敗", loginFailed: "登入失敗", operationFailed: "操作失敗", loadFailed: "載入失敗", settingsFailed: "設定失敗", loginFailed: "登入失敗",
rememberFailed: "無法記住這則訊息", clipboardWriteBlocked: "瀏覽器封鎖剪貼簿寫入。", rememberFailed: "無法記住這則訊息", clipboardWriteBlocked: "瀏覽器封鎖剪貼簿寫入。",
@ -20,23 +20,46 @@ export const zhTW = {
chooseConversation: "選擇對話", deleteConversation: "刪除對話", clearConversation: "清除此對話", chooseConversation: "選擇對話", deleteConversation: "刪除對話", clearConversation: "清除此對話",
clearConversationTitle: "清除此對話?", clearConversationDescription: "訊息會刪除。這個 Agent 的長期記憶會保留。", cancel: "取消", clearMessages: "清除訊息", clearConversationTitle: "清除此對話?", clearConversationDescription: "訊息會刪除。這個 Agent 的長期記憶會保留。", cancel: "取消", clearMessages: "清除訊息",
deleteConversationTitle: "刪除這則對話?", deleteConversationDescription: "這則對話會從列表消失。這個 Agent 的長期記憶會保留。", deleteConversationTitle: "刪除這則對話?", deleteConversationDescription: "這則對話會從列表消失。這個 Agent 的長期記憶會保留。",
avatarAppearance: "頭像外觀", avatarAppearanceHint: "只使用色塊與眼睛", color: "顏色", chooseColor: "選擇 {value}", customColor: "自訂顏色", shape: "形狀", name: "名稱", tags: "標籤", avatarAppearance: "頭像外觀", avatarAppearanceHint: "顏色、形狀、表情與背景都可選,眼睛會跟著游標", color: "顏色", chooseColor: "選擇 {value}", customColor: "自訂顏色", shape: "形狀",
expression: "表情", background: "背景", bgNone: "無", bgCircle: "圓", bgSquircle: "方圓", bgSquare: "方",
exprIdle: "閒置", exprHappy: "開心", exprSad: "難過", exprMad: "生氣", exprSurprised: "驚訝", exprWink: "眨眼",
exprSleepy: "想睡", exprSmug: "得意", exprUnsure: "猶豫", exprScared: "害怕", exprLove: "喜歡", exprShy: "害羞",
exprSick: "不適", exprThinking: "思考",
name: "名稱", tags: "標籤",
tagsPlaceholder: "研究、設計、客服(用逗號分隔)", tagsLimit: "最多儲存 6 個標籤", shortTitle: "簡短標題", shortTitlePlaceholder: "例如:產品研究助理", description: "說明", descriptionPlaceholder: "說明這個機器人的用途與工作範圍", tagsPlaceholder: "研究、設計、客服(用逗號分隔)", tagsLimit: "最多儲存 6 個標籤", shortTitle: "簡短標題", shortTitlePlaceholder: "例如:產品研究助理", description: "說明", descriptionPlaceholder: "說明這個機器人的用途與工作範圍",
saving: "儲存中…", saveSettings: "儲存設定", deleteBot: "刪除機器人", saving: "儲存中…", saveSettings: "儲存設定", deleteBot: "刪除機器人",
memoryHelp: "清除對話不會刪這些。只存明確偏好或事實,不要存密碼。", enableLongTermMemory: "啟用長期記憶", addMemory: "新增記憶", memoryPlaceholder: "只儲存明確偏好或事實;密碼與 token 會被拒絕。", memoryHelp: "清除對話不會刪這些。只存明確偏好或事實,不要存密碼。", enableLongTermMemory: "啟用長期記憶", addMemory: "新增記憶", memoryPlaceholder: "只儲存明確偏好或事實;密碼與 token 會被拒絕。",
memorySearch: "搜尋", filterMemory: "過濾記憶", noMemory: "還沒有長期記憶。對話裡講過的偏好,可以叫 Agent 記住,或你在這裡新增。", noMatchingMemory: "沒有符合的記憶。", memorySearch: "搜尋", filterMemory: "過濾記憶", noMemory: "還沒有長期記憶。對話裡講過的偏好,可以叫 Agent 記住,或你在這裡新增。", noMatchingMemory: "沒有符合的記憶。",
save: "儲存", edit: "編輯", clearAllMemoryConfirm: "確定清除全部記憶?", clearMemoryConfirm: "確定清除", clearAll: "全部清除", save: "儲存", edit: "編輯", clearAllMemoryConfirm: "確定清除全部記憶?", clearMemoryConfirm: "確定清除", clearAll: "全部清除",
mcpHelp: "接入 MCP server 後,所有 Agent 都能使用它的工具。stdio 會在 API 主機上啟動行程HTTP / SSE 連到遠端。", mcpNamePlaceholder: "例如github", mcpHelp: "點「選擇 MCP」從市集接入。標了需要金鑰的先填 token 才能連。本機 stdio 會在 API 容器裡啟動。", mcpNamePlaceholder: "例如github", mcpConnectFailed: "接入失敗,請檢查金鑰或稍後重試。",
command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP", command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP",
noMcp: "還沒有 MCP。接入後工具會顯示在這裡Agent 對話時可以使用。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用", chooseMcp: "選擇 MCP", mcpPickerTitle: "選擇 MCP", mcpPickerLead: "從精選或官方市集挑一個,點下去接入。所有 Agent 都能用它的工具。",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", stopAndTakeOver: "停止並接管", searchMcp: "搜尋 MCP", mcpRemote: "遠端", mcpLocal: "本機", mcpAdded: "已接入", mcpNeedsKey: "需要金鑰",
mcpNoResults: "找不到符合的 MCP。換個關鍵字或改用自訂接入。", mcpCustom: "自訂接入", mcpBackToList: "回到列表",
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管",
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC", pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立", botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組", groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
deleteGroup: "刪除群組", deleteNamed: "刪除 {name}", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。", deleteGroup: "刪除群組", deleteNamed: "刪除 {name}", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。",
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP例如 http://192.168.x.x:3101。", phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP例如 http://192.168.x.x:3101。",
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄", close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
workspaceSettingsHint: "機器人外觀、記憶與 MCP 外掛在右側分頁。這裡只改這個工作區的顯示。", aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。", workspaceSettingsHint: "機器人外觀、記憶與 MCP 外掛在右側分頁。這裡只改這個工作區的顯示。",
modelSettings: "模型", modelSettingsHint: "這裡的供應商與模型會套用到還沒單獨指定模型的機器人。",
modelProvider: "模型供應商", providerXai: "xAI", providerOpencodeGo: "OpenCode Go", providerOpenaiCompatible: "OpenAI 相容",
providerXaiHint: "Grok。金鑰從 console.x.ai 取得。",
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
usingEnvKey: "目前會用環境變數 {name}。",
clearApiKey: "清除已存金鑰",
modelId: "模型", modelIdPlaceholder: "例如qwen2.5 或 llama3.1",
reloadModels: "重新載入模型列表",
baseUrl: "Base URL", baseUrlPlaceholder: "http://127.0.0.1:11434/v1",
baseUrlHint: "API 跑在 Docker 時,本機服務請填 host.docker.internal 而不是 localhost。",
workspaceModelHint: "先選供應商再填金鑰與模型。OpenAI 相容需要自己填端點。",
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…", apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。", helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。", helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",

View File

@ -1,5 +1,7 @@
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "@fontsource/huninn"; import "@fontsource/huninn";
import "blobatar/motion.css";
import "blobatar/gaze.css";
import "./styles.css"; import "./styles.css";
import "./refinements.css"; import "./refinements.css";
import { App } from "./App"; import { App } from "./App";

View File

@ -2,20 +2,9 @@
.app-shell{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,28vw,420px)} .app-shell{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,28vw,420px)}
.sidebar,.chat-panel,.computer-panel,.topbar,.panel-head,.control-bar{min-width:0} .sidebar,.chat-panel,.computer-panel,.topbar,.panel-head,.control-bar{min-width:0}
.session-picker select{max-width:190px;min-width:90px;padding:7px 28px 7px 10px;border:1px solid var(--border);border-radius:9px;color:var(--ink);background:var(--surface)} .session-picker select{max-width:190px;min-width:90px;padding:7px 28px 7px 10px;border:1px solid var(--border);border-radius:9px;color:var(--ink);background:var(--surface)}
.avatar.robot,.avatar.robot.online{position:relative;display:inline-grid;place-items:center;flex:0 0 var(--avatar-size);width:var(--avatar-size);height:var(--avatar-size);overflow:visible;border:0;background:radial-gradient(circle at 34% 24%,color-mix(in srgb,var(--bot-color),white 30%),var(--bot-color) 58%,color-mix(in srgb,var(--bot-color),black 28%));box-shadow:0 2px max(4px,calc(var(--avatar-size)*.15)) rgba(0,0,0,.35),inset 0 1px 1px rgba(255,255,255,.32)} .avatar.blobatar,.avatar.blobatar.online{position:relative;display:inline-grid;place-items:center;flex:0 0 var(--avatar-size);width:var(--avatar-size);height:var(--avatar-size);overflow:visible;border:0;border-radius:0;background:transparent;box-shadow:none;color:unset}
.avatar-blob{border-radius:58% 42% 52% 48%/43% 58% 42% 57%;transform:rotate(-3deg)} .avatar.blobatar>svg,.avatar.blobatar>img{display:block;width:100%;height:100%}
.avatar-round{border-radius:50%}.avatar-diamond{border-radius:26%;transform:rotate(45deg)}.avatar-diamond .robot-eyes{transform:rotate(-45deg)}.avatar-squircle{border-radius:28%} .avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after{content:"";position:absolute;z-index:4;inset:-6px;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 8%,#ff4fd8 11%,#7c5cff 18%,transparent 25% 43%,#34d9ff 48%,#58f39a 55%,transparent 62% 78%,#ffe66d 82%,#ff7a59 89%,transparent 96%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));pointer-events:none;animation:magic-orbit 1.35s linear infinite}.avatar.blobatar.thinking:after{inset:-8px;opacity:.45;filter:blur(3px);animation-duration:2.1s;animation-direction:reverse}
.avatar.robot.avatar-organic,.avatar.robot.avatar-organic.online{border-radius:0;background:transparent;box-shadow:none}.avatar-shape{position:absolute;z-index:0;inset:0;width:100%;height:100%;overflow:visible;filter:drop-shadow(0 2px 2px rgba(0,0,0,.3))}.avatar-shape path{fill:var(--bot-color);stroke:color-mix(in srgb,var(--bot-color),white 22%);stroke-width:.8;paint-order:stroke fill}
.robot-eyes{position:relative;z-index:2;display:flex;align-items:center;justify-content:center;gap:calc(var(--avatar-size) * .13)}
.robot-eyes i{display:block;width:calc(var(--avatar-size) * .13);height:calc(var(--avatar-size) * .27);min-width:3px;min-height:7px;border-radius:999px;background:#101014;animation:robot-look 3.2s ease-in-out infinite}
.robot-face{position:relative;width:21px;height:12px;border:2px solid rgba(8,12,16,.78);border-radius:7px 7px 9px 9px;transform-origin:center}
.robot-face:before{content:"";position:absolute;top:-6px;left:8px;width:2px;height:5px;border-radius:2px;background:rgba(8,12,16,.78)}
.robot-face i{position:absolute;top:3px;width:3px;height:4px;border-radius:50%;background:#081014;animation:robot-look 3.2s ease-in-out infinite}
.robot-face i:first-child{left:4px}.robot-face i:last-child{right:4px}
.avatar.robot.thinking{animation:robot-breathe 1.25s ease-in-out infinite}
.avatar.robot.thinking:before,.avatar.robot.thinking:after{content:"";position:absolute;z-index:4;inset:-6px;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 8%,#ff4fd8 11%,#7c5cff 18%,transparent 25% 43%,#34d9ff 48%,#58f39a 55%,transparent 62% 78%,#ffe66d 82%,#ff7a59 89%,transparent 96%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));pointer-events:none;animation:magic-orbit 1.35s linear infinite}.avatar.robot.thinking:after{inset:-8px;opacity:.45;filter:blur(3px);animation-duration:2.1s;animation-direction:reverse}
.avatar.robot.thinking .robot-eyes{animation:robot-tilt 1.8s ease-in-out infinite}
.avatar.robot.thinking .robot-eyes i{animation:robot-blink 1.8s ease-in-out infinite}
.magic-particles{position:absolute;z-index:4;inset:-12px;pointer-events:none;animation:magic-orbit 2.4s linear infinite}.magic-particles i{position:absolute;width:4px;height:4px;border-radius:50%;background:#fff;box-shadow:0 0 7px 2px #6df,0 0 12px #d5f}.magic-particles i:nth-child(1){top:0;left:48%}.magic-particles i:nth-child(2){right:0;top:45%;background:#ffe66d}.magic-particles i:nth-child(3){bottom:1px;left:35%;background:#ff73dc}.magic-particles i:nth-child(4){left:0;top:30%;background:#79ffb0} .magic-particles{position:absolute;z-index:4;inset:-12px;pointer-events:none;animation:magic-orbit 2.4s linear infinite}.magic-particles i{position:absolute;width:4px;height:4px;border-radius:50%;background:#fff;box-shadow:0 0 7px 2px #6df,0 0 12px #d5f}.magic-particles i:nth-child(1){top:0;left:48%}.magic-particles i:nth-child(2){right:0;top:45%;background:#ffe66d}.magic-particles i:nth-child(3){bottom:1px;left:35%;background:#ff73dc}.magic-particles i:nth-child(4){left:0;top:30%;background:#79ffb0}
.messages{min-width:0} .messages{min-width:0}
.message{box-sizing:border-box;width:min(760px,100%);max-width:none;margin-inline:auto;overflow-wrap:anywhere} .message{box-sizing:border-box;width:min(760px,100%);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
@ -33,11 +22,7 @@
.bot-row{gap:9px}.bot-copy{display:grid;min-width:0;flex:1}.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)} .bot-row{gap:9px}.bot-copy{display:grid;min-width:0;flex:1}.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)}
.avatar-wrap{position:relative;display:grid;flex:0 0 auto}.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)}.bot-group{display:grid;gap:3px}.group-label{padding:12px 11px 4px;color:var(--muted);font-size:11px;letter-spacing:.04em}.row-time{align-self:start;color:var(--muted);font-size:10px;white-space:nowrap}.row-pin{width:13px;height:13px;color:var(--muted);transform:rotate(-20deg)}.hidden-toggle{display:flex;align-items:center;gap:7px;margin:4px 12px;padding:8px;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}.hidden-toggle svg{width:14px} .avatar-wrap{position:relative;display:grid;flex:0 0 auto}.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)}.bot-group{display:grid;gap:3px}.group-label{padding:12px 11px 4px;color:var(--muted);font-size:11px;letter-spacing:.04em}.row-time{align-self:start;color:var(--muted);font-size:10px;white-space:nowrap}.row-pin{width:13px;height:13px;color:var(--muted);transform:rotate(-20deg)}.hidden-toggle{display:flex;align-items:center;gap:7px;margin:4px 12px;padding:8px;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}.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 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:hover{background:rgba(255,255,255,.07)}.context-menu button svg{width:16px;height:16px}.context-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}.context-menu .danger-item{color:#ff7777}.context-menu .context-close{display:none} .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 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:hover{background:rgba(255,255,255,.07)}.context-menu button svg{width:16px;height:16px}.context-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}.context-menu .danger-item{color:#ff7777}.context-menu .context-close{display:none}
.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}.avatar-editor{display:grid;justify-items:center;gap:5px;padding:8px 0 2px}.avatar-editor strong{margin-top:7px}.avatar-editor small,.settings-dialog label small{color:var(--muted)}.settings-dialog fieldset{margin:0;padding:0;border:0}.settings-dialog legend{margin-bottom:9px;color:var(--muted);font-size:13px}.color-grid,.shape-grid{display:flex;gap:10px;flex-wrap:wrap}.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}.custom-color{display:grid;place-items:center;overflow:hidden;border:1px dashed var(--muted)}.custom-color input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.custom-color span{font-size:20px}.shape-grid button{display:grid;place-items:center;width:54px;height:54px;border:1px solid var(--border);border-radius:12px;background:transparent}.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:focus{border-color:var(--accent)} .settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}.avatar-editor{display:grid;justify-items:center;gap:5px;padding:18px 0 2px}.avatar-editor strong{margin-top:7px}.avatar-editor small,.settings-dialog label small{color:var(--muted)}.settings-dialog fieldset{margin:0;padding:0;border:0}.settings-dialog legend{margin-bottom:9px;color:var(--muted);font-size:13px}.color-grid,.shape-grid{display:flex;gap:10px;flex-wrap:wrap}.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}.custom-color{display:grid;place-items:center;overflow:hidden;border:1px dashed var(--muted)}.custom-color input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.custom-color span{font-size:20px}.shape-grid button{display:grid;place-items:center;width:54px;height:54px;border:1px solid var(--border);border-radius:12px;background:transparent}.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:focus{border-color:var(--accent)}
@keyframes robot-look{0%,40%,100%{transform:translateX(0)}50%,65%{transform:translateX(2px)}75%,90%{transform:translateX(-2px)}}
@keyframes robot-breathe{0%,100%{transform:scale(1);filter:saturate(1)}50%{transform:scale(1.06);filter:saturate(1.25) brightness(1.08)}}
@keyframes robot-tilt{0%,100%{transform:rotate(0)}30%{transform:rotate(-7deg)}70%{transform:rotate(7deg)}}
@keyframes robot-blink{0%,35%,43%,100%{transform:scaleY(1)}39%{transform:scaleY(.12)}}
@keyframes thinking-dot{0%,60%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}} @keyframes thinking-dot{0%,60%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
@keyframes magic-orbit{to{transform:rotate(360deg)}} @keyframes magic-orbit{to{transform:rotate(360deg)}}
@ -46,14 +31,15 @@
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}} @media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}} @media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.app-shell{display:block}.message{width:100%}.message>span{max-width:90%}.composer{left:50%;right:auto;width:calc(100% - 24px);min-height:60px}} @media(max-width:700px){.app-shell{display:block}.message{width:100%}.message>span{max-width:90%}.composer{left:50%;right:auto;width:calc(100% - 24px);min-height:60px}}
@media(prefers-reduced-motion:reduce){.avatar.robot,.robot-eyes,.robot-eyes i,.thinking-dots i{animation:none!important}} @media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
.avatar-capsule{border-radius:999px;transform:scaleX(1.16)}.avatar-capsule .robot-eyes{transform:scaleX(.86)}
.avatar-triangle{box-sizing:border-box;clip-path:polygon(50% 4%,96% 91%,4% 91%);border-radius:0;padding:0}.avatar-triangle .robot-eyes{transform:translateY(calc(var(--avatar-size) * .12))}
.avatar-hexagon{clip-path:polygon(25% 5%,75% 5%,100% 50%,75% 95%,25% 95%,0 50%)}
.avatar-cloud .robot-eyes{transform:translateY(calc(var(--avatar-size) * .1))}.avatar-drop .robot-eyes{transform:translateY(calc(var(--avatar-size) * .12))}.avatar-cat .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.08))}.avatar-bunny .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.1))}.avatar-star .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.02)) scale(.68)}.avatar-heart .robot-eyes{transform:translateY(calc(var(--avatar-size) * -0.1))}.avatar-egg .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.08))}.avatar-ghost .robot-eyes{transform:translateY(calc(var(--avatar-size) * -0.03))}.avatar-sprout .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.12))}.avatar-cactus .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.05))}.avatar-mushroom .robot-eyes{transform:translateY(calc(var(--avatar-size) * -0.12))}.avatar-paw .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.14))}
.shape-grid button{border-color:transparent;border-radius:50%}.shape-grid button:hover{background:rgba(255,255,255,.04)}.shape-grid button.selected{border-color:#626268;box-shadow:none;background:rgba(255,255,255,.025)} .shape-grid button{border-color:transparent;border-radius:50%}.shape-grid button:hover{background:rgba(255,255,255,.04)}.shape-grid button.selected{border-color:#626268;box-shadow:none;background:rgba(255,255,255,.025)}
.expr-grid,.bg-grid{display:flex;gap:8px;flex-wrap:wrap}
.expr-grid button,.bg-grid button{display:grid;justify-items:center;gap:3px;min-width:54px;padding:6px 4px 7px;border:1px solid transparent;border-radius:12px;background:transparent;color:var(--muted);font:inherit;font-size:10px;cursor:pointer}
.expr-grid button:hover,.bg-grid button:hover{background:rgba(255,255,255,.04)}
.expr-grid button.selected,.bg-grid button.selected{border-color:#626268;color:var(--ink);background:rgba(255,255,255,.025)}
.avatar-editor .avatar.blobatar{margin-bottom:2px}
.bot-list{flex:1;min-height:0} .bot-list{flex:1;min-height:0}
.sidebar-bottom{display:flex;flex-direction:column;align-items:stretch;gap:4px;padding-top:10px}.sidebar-bottom .account{min-width:0;flex:1}.account>.avatar .avatar-shape{width:100%;height:100%;color:unset} .sidebar-bottom{display:flex;flex-direction:column;align-items:stretch;gap:4px;padding-top:10px}.sidebar-bottom .account{min-width:0;flex:1}
.account-wrap{position:relative;width:100%} .account-wrap{position:relative;width:100%}
.account{cursor:pointer} .account{cursor:pointer}
.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)} .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)}
@ -101,6 +87,26 @@
.mcp-tool code{font-size:12px;color:var(--accent)} .mcp-tool code{font-size:12px;color:var(--accent)}
.mcp-tool span{color:var(--muted);font-size:12px} .mcp-tool span{color:var(--muted);font-size:12px}
.mcp-card-actions{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 12px} .mcp-card-actions{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 12px}
.mcp-choose{display:inline-flex;align-items:center;gap:8px;width:fit-content}.mcp-choose svg{width:16px;height:16px}
.mcp-picker{width:min(760px,calc(100vw - 28px));max-height:min(800px,calc(100dvh - 24px));display:flex;flex-direction:column;gap:12px;overflow:hidden;min-height:0}
.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}
.mcp-picker-search>div{flex:0 0 auto;display:grid;place-items:center;line-height:0}
.dialog .mcp-picker-search input,.mcp-picker-search input{width:100%;height:auto;min-height:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--ink)}
.dialog .mcp-picker-search input:focus{border-color:transparent}
.mcp-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));grid-auto-rows:min-content;gap:8px;flex:1 1 auto;min-height:0;overflow:auto;padding:2px 6px 8px 0;align-content:start;scrollbar-gutter:stable}
.mcp-picker-grid::-webkit-scrollbar{width:8px}
.mcp-picker-grid::-webkit-scrollbar-thumb{background:#2a2a2e;border-radius:999px}
.mcp-picker-empty{grid-column:1/-1;padding:28px 8px;color:var(--muted);text-align:center}
.mcp-pick-card{display:grid;gap:6px;align-content:start;min-height:min-content;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--inset);color:inherit;text-align:left;cursor:pointer}
.mcp-pick-card:hover:not(.added):not(:disabled){border-color:var(--accent)}
.mcp-pick-card.added,.mcp-pick-card:disabled{opacity:.45;cursor:default}
.mcp-pick-icon{width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:rgba(62,197,168,.12);color:var(--accent);font-weight:700}
.mcp-pick-card strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mcp-pick-card small{color:var(--muted);font-size:12px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mcp-pick-meta{color:var(--faint);font-size:11px}
.mcp-secret-step,.mcp-picker .mcp-add{display:grid;gap:10px;flex:1 1 auto;min-height:0;overflow:auto;align-content:start}
.mcp-picker .mcp-back{width:fit-content;justify-self:start}
.mcp-card-actions svg{width:13px;height:13px} .mcp-card-actions svg{width:13px;height:13px}
.create-menu-wrap{position:relative;margin-left:auto}.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 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:hover:not(:disabled){background:rgba(255,255,255,.07)}.create-menu button:disabled{opacity:.4;cursor:not-allowed}.create-menu svg{width:16px} .create-menu-wrap{position:relative;margin-left:auto}.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 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:hover:not(:disabled){background:rgba(255,255,255,.07)}.create-menu button:disabled{opacity:.4;cursor:not-allowed}.create-menu svg{width:16px}
.group-picker{display:grid;gap:5px;max-height:280px;margin:0;padding:0;overflow:auto;border:0}.group-picker legend{margin-bottom:8px;color:var(--muted)}.group-picker label{display:flex;align-items:center;gap:10px;padding:7px 9px;border-radius:9px;background:var(--inset);cursor:pointer}.group-picker input{width:16px;height:16px;margin:0}.group-picker .avatar{--avatar-size:28px!important} .group-picker{display:grid;gap:5px;max-height:280px;margin:0;padding:0;overflow:auto;border:0}.group-picker legend{margin-bottom:8px;color:var(--muted)}.group-picker label{display:flex;align-items:center;gap:10px;padding:7px 9px;border-radius:9px;background:var(--inset);cursor:pointer}.group-picker input{width:16px;height:16px;margin:0}.group-picker .avatar{--avatar-size:28px!important}
@ -187,38 +193,28 @@
.avatar .presence{display:none} .avatar .presence{display:none}
.bot-row .avatar-wrap::after{content:"";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} .bot-row .avatar-wrap::after{content:"";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}
.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;background:#3b82f6;box-shadow:0 0 8px rgba(59,130,246,.65)}
.avatar.robot.thinking .avatar-shape{animation:avatar-rainbow-glow 1.8s linear infinite} .avatar.blobatar.thinking{animation:avatar-rainbow-glow 1.8s linear infinite}
.avatar-wrap{transition:transform .16s ease} .avatar-wrap{transition:transform .16s ease}
.bot-row:hover .avatar-wrap{transform:scale(1.06)} .bot-row:hover .avatar-wrap{transform:scale(1.06)}
.avatar-stack{position:relative;display:block;flex:0 0 auto} .avatar-stack{position:relative;display:block;flex:0 0 auto}
.avatar-stack .stack-item{position:absolute;top:0} .avatar-stack .stack-item{position:absolute;top:0}
.avatar-stack .stack-item .avatar{box-shadow:0 0 0 2px var(--side,#0b0b0c)}.avatar-stack .stack-item .avatar.avatar-triangle,.avatar-stack .stack-item .avatar.avatar-hexagon,.avatar-stack .stack-item .avatar.avatar-organic{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.thinking{filter:none} .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.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,#0b0b0c)}
.bot-row.room-row .bot-copy small{color:var(--muted)} .bot-row.room-row .bot-copy small{color:var(--muted)}
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end} .message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
.message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal} .message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal}
.message.assistant .msg-avatar>.avatar.robot{max-width:none;padding:0;background:radial-gradient(circle at 34% 24%,color-mix(in srgb,var(--bot-color),white 30%),var(--bot-color) 58%,color-mix(in srgb,var(--bot-color),black 28%));color:inherit;line-height:normal;white-space:normal} .message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
.message.assistant .msg-avatar>.avatar.robot.avatar-organic{background:transparent}
.message .msg-avatar .avatar span{max-width:none;padding:0;border-radius:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
.message.assistant.spoken .speaker{grid-column:2;font-size:12px;font-weight:650;line-height:1.2;padding:0 6px;color:var(--accent)} .message.assistant.spoken .speaker{grid-column:2;font-size:12px;font-weight:650;line-height:1.2;padding:0 6px;color:var(--accent)}
.message.assistant.spoken>span{grid-column:2;max-width:82%;border-radius:6px 18px 18px 18px} .message.assistant.spoken>span{grid-column:2;max-width:82%;border-radius:6px 18px 18px 18px}
.thinking-row{width:min(760px,100%);margin:2px auto 14px;padding-left:2px} .thinking-row{width:min(760px,100%);margin:2px auto 14px;padding-left:2px}
.dialog-lead{margin:0;color:var(--muted);line-height:1.5;font-size:13px} .dialog-lead{margin:0;color:var(--muted);line-height:1.5;font-size:13px}
.topbar .avatar-stack{width:26px!important;height:26px!important;margin-right:2px;transform:scale(.6842);transform-origin:center} .topbar .avatar-stack{width:26px!important;height:26px!important;margin-right:2px;transform:scale(.6842);transform-origin:center}
/* Final visual polish: keep the small robot faces legible without making the
eyes dominate the avatar, and give stacked room avatars a deliberate rhythm. */
.robot-eyes{gap:calc(var(--avatar-size) * .12)}
.robot-eyes i{width:calc(var(--avatar-size) * .095);height:calc(var(--avatar-size) * .2);min-width:2px;min-height:4px}
.robot-face{width:18px;height:10px;border-width:1.5px}
.robot-face:before{top:-5px;left:7px;height:4px}
.robot-face i{top:2px;width:2px;height:3px}
.robot-face i:first-child{left:3px}.robot-face i:last-child{right:3px}
.avatar-stack{isolation:isolate} .avatar-stack{isolation:isolate}
.avatar-stack .stack-item{transition:transform .16s ease} .avatar-stack .stack-item{transition:transform .16s ease}
.avatar-stack .stack-item:hover{transform:translateY(-2px);z-index:8!important} .avatar-stack .stack-item:hover{transform:translateY(-2px);z-index:8!important}
.avatar-stack .stack-item .avatar,.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,#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-item .avatar.avatar-organic{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.thinking{filter:none} .avatar-stack .stack-item .avatar.thinking{filter:none}
/* Speaker rows: the coloured speaker label must breathe away from the text. */ /* Speaker rows: the coloured speaker label must breathe away from the text. */
@ -255,3 +251,14 @@
.message.assistant.spoken{grid-template-columns:24px minmax(0,1fr);column-gap:8px} .message.assistant.spoken{grid-template-columns:24px minmax(0,1fr);column-gap:8px}
.computer-part .preview{max-height:none} .computer-part .preview{max-height:none}
} }
.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}
.computer-actions .primary{min-width:0}
.computer-overlay header .computer-actions{justify-content:flex-end}
.provider-fieldset{margin:0;padding:0;border:0}.provider-fieldset legend{margin-bottom:8px;color:var(--muted);font-size:13px}
.provider-grid{display:flex;gap:6px;flex-wrap:wrap}
.provider-grid button{height:36px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}
.provider-grid button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
.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)}

View File

@ -1,6 +1,8 @@
export type ComputerMode = "team" | "dedicated"; export type ComputerMode = "team" | "dedicated";
export type ComputerState = "stopped" | "booting" | "running" | "suspended" | "error"; export type ComputerState = "stopped" | "booting" | "running" | "suspended" | "error";
export type AvatarShape = "round"|"blob"|"squircle"|"capsule"|"triangle"|"hexagon"|"cloud"|"drop"|"diamond"|"organic-4"|"organic-5"|"organic-6"|"organic-7"|"organic-8"|"organic-9"|"organic-10"|"organic-11"|"cat"|"bunny"|"star"|"heart"|"egg"|"ghost"|"sprout"|"cactus"|"mushroom"|"paw"; export type BlobatarShape = "round"|"organic"|"boxy"|"capsule"|"nub"|"cloud"|"droplet"|"hexagon"|"sun"|"triangle";
/** Stored values include pre-blobatar aliases so existing bots keep rendering. */
export type AvatarShape = BlobatarShape|"blob"|"squircle"|"diamond"|"drop"|"organic-4"|"organic-5"|"organic-6"|"organic-7"|"organic-8"|"organic-9"|"organic-10"|"organic-11"|"cat"|"bunny"|"star"|"heart"|"egg"|"ghost"|"sprout"|"cactus"|"mushroom"|"paw";
export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean } export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean }
export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number } export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number }
export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null } export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null }
@ -11,3 +13,9 @@ export interface MemoryItem { id:string; sessionId:string|null; sourceRunId:stri
export type McpTransport = "stdio" | "http" | "sse"; export type McpTransport = "stdio" | "http" | "sse";
export interface McpTool { name:string; exposedName:string; description:string } export interface McpTool { name:string; exposedName:string; description:string }
export interface McpServer { id:string; name:string; transport:McpTransport; command:string|null; args:string[]; env:Record<string,string>; url:string|null; headers:Record<string,string>; enabled:boolean; status:"connected"|"disconnected"|"disabled"; error:string|null; tools:McpTool[]; createdAt:string; updatedAt:string } export interface McpServer { id:string; name:string; transport:McpTransport; command:string|null; args:string[]; env:Record<string,string>; url:string|null; headers:Record<string,string>; enabled:boolean; status:"connected"|"disconnected"|"disabled"; error:string|null; tools:McpTool[]; createdAt:string; updatedAt:string }
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 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[] }

View File

@ -1,5 +1,5 @@
use axum::extract::{Request, State}; use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Json, Router}; use axum::{Json, Router};
@ -104,7 +104,10 @@ struct LoginInput {
pub fn public_router(state: AppState) -> Router { pub fn public_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/api/session", axum::routing::get(session).post(login).delete(logout)) .route(
"/api/session",
axum::routing::get(session).post(login).delete(logout),
)
.with_state(state) .with_state(state)
} }
@ -144,18 +147,12 @@ async fn logout() -> Response {
let mut response = Json(json!({"ok": true})).into_response(); let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert( response.headers_mut().insert(
header::SET_COOKIE, header::SET_COOKIE,
HeaderValue::from_static( HeaderValue::from_static("lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
"lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
),
); );
response response
} }
pub async fn require_auth( pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if state.auth.valid_session(request.headers()) { if state.auth.valid_session(request.headers()) {
next.run(request).await next.run(request).await
} else { } else {

View File

@ -3,19 +3,19 @@ use std::time::Duration;
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use lazyboy_contracts::{ use lazyboy_contracts::{
BrowserProfileMode, ComputerCapabilities, ComputerMode, ComputerState, ComputerStatus, ControlHolder, BrowserProfileMode, ComputerCapabilities, ComputerMode, ComputerState, ComputerStatus,
DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH, ControlHolder, DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH,
}; };
use lazyboy_control::{ use lazyboy_control::{
admit_gui, admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key, AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui,
screen_layout, team_bot_workspace_directory, user_holds_control, AdapterContext, CommandRequest, admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key,
EnsureScreenRequest, ProvisionRequest, screen_layout, team_bot_workspace_directory, user_holds_control,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::db::{ use crate::db::{
parse_holder, parse_kind, parse_mode, parse_profile_mode, parse_run_status, parse_state, Actor, ComputerRow, Actor, ComputerRow, ScreenRow, parse_holder, parse_kind, parse_mode, parse_profile_mode,
ScreenRow, parse_run_status, parse_state,
}; };
use crate::state::AppState; use crate::state::AppState;
@ -143,12 +143,14 @@ pub async fn ensure_bot_screen(
let profile_path = browser_profile_path(profile_mode, bot_id, run_id); let profile_path = browser_profile_path(profile_mode, bot_id, run_id);
let row = if let Some(existing) = existing { let row = if let Some(existing) = existing {
if existing.profile_path != profile_path && profile_mode == BrowserProfileMode::PerTask { if existing.profile_path != profile_path && profile_mode == BrowserProfileMode::PerTask {
sqlx::query("UPDATE computer_screens SET profile_path = $2, updated_at = now() WHERE id = $1") sqlx::query(
.bind(&existing.id) "UPDATE computer_screens SET profile_path = $2, updated_at = now() WHERE id = $1",
.bind(&profile_path) )
.execute(state.pool()) .bind(&existing.id)
.await .bind(&profile_path)
.map_err(|error| error.to_string())?; .execute(state.pool())
.await
.map_err(|error| error.to_string())?;
} }
state state
.db .db
@ -179,12 +181,19 @@ pub async fn ensure_bot_screen(
.await; .await;
match inserted { match inserted {
Ok(row) => row, Ok(row) => row,
Err(error) if error.to_string().contains("computer_screens_computer_id_slot") => { Err(error)
if error
.to_string()
.contains("computer_screens_computer_id_slot") =>
{
return Ok(BoundScreen { return Ok(BoundScreen {
row: None, row: None,
gui_block: Some(admit_new_screen(&caps, &used, None).err().map(|block| block.message()).unwrap_or_else(|| { gui_block: Some(
lazyboy_contracts::TEAM_SCREENS_FULL.to_string() admit_new_screen(&caps, &used, None)
})), .err()
.map(|block| block.message())
.unwrap_or_else(|| lazyboy_contracts::TEAM_SCREENS_FULL.to_string()),
),
}); });
} }
Err(error) => return Err(error.to_string()), Err(error) => return Err(error.to_string()),
@ -198,7 +207,11 @@ pub async fn ensure_bot_screen(
}; };
let mut last_error = None; let mut last_error = None;
for attempt in 0..8 { for attempt in 0..8 {
match state.sandbox.ensure_screen(&computer_ref, request.clone(), &ctx).await { match state
.sandbox
.ensure_screen(&computer_ref, request.clone(), &ctx)
.await
{
Ok(_) => { Ok(_) => {
last_error = None; last_error = None;
break; break;
@ -255,23 +268,88 @@ async fn restore_computer_screens(
} }
} }
async fn guest_has_screens(state: &AppState, actor: &Actor, bot_id: &str, computer: &ComputerRow) -> bool { async fn guest_has_screens(
state: &AppState,
actor: &Actor,
bot_id: &str,
computer: &ComputerRow,
) -> bool {
matches!(
probe_computer_container(state, actor, bot_id, computer).await,
ContainerProbe::Alive
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ContainerProbe {
Alive,
Missing,
Unknown,
}
fn container_missing_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("is not running")
|| error.contains("no such container")
|| error.contains("404 not found")
|| error.contains("status code 409")
}
async fn probe_computer_container(
state: &AppState,
actor: &Actor,
bot_id: &str,
computer: &ComputerRow,
) -> ContainerProbe {
let Some(computer_ref) = computer_ref(computer) else { let Some(computer_ref) = computer_ref(computer) else {
return false; return ContainerProbe::Missing;
}; };
let result = state match state
.sandbox .sandbox
.execute( .execute(
&computer_ref, &computer_ref,
CommandRequest { CommandRequest {
argv: vec!["test".into(), "-x".into(), "/usr/local/bin/lazyboy-screen".into()], argv: vec![
"test".into(),
"-x".into(),
"/usr/local/bin/lazyboy-screen".into(),
],
cwd: None, cwd: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
}, },
&adapter_context(actor, bot_id, "probe"), &adapter_context(actor, bot_id, "probe"),
) )
.await; .await
matches!(result, Ok(output) if output.code == 0) {
Ok(output) if output.code == 0 => ContainerProbe::Alive,
Ok(_) => ContainerProbe::Missing,
Err(error) if container_missing_error(&error.to_string()) => ContainerProbe::Missing,
Err(error) => {
tracing::warn!("computer {} probe failed: {error}", computer.id);
ContainerProbe::Unknown
}
}
}
async fn mark_computer_missing(state: &AppState, computer: &mut ComputerRow) {
if computer.state != "running" {
return;
}
tracing::warn!(
"computer {} is marked running but the container is gone",
computer.id
);
if let Err(error) = sqlx::query(
"UPDATE computers SET state = 'error', updated_at = now() WHERE id = $1 AND state = 'running'",
)
.bind(&computer.id)
.execute(state.pool())
.await
{
tracing::error!("failed to mark computer {} missing: {error}", computer.id);
return;
}
computer.state = "error".into();
} }
pub async fn take_screen_execution( pub async fn take_screen_execution(
@ -371,7 +449,11 @@ pub async fn take_profile_lock(
) )
.err() .err()
.map(|block| block.message()) .map(|block| block.message())
.unwrap_or_else(|| format!("Another bot is using this shared browser profile. Currently in use by {other}.")), .unwrap_or_else(|| {
format!(
"Another bot is using this shared browser profile. Currently in use by {other}."
)
}),
)) ))
} }
@ -382,7 +464,10 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -419,31 +504,56 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if claimed.rows_affected() != 1 && computer.state != "booting" { // Only the request that atomically transitions the row to booting may
// provision it. Allowing every caller that observes `booting` through
// here starts duplicate containers and can leave the row inconsistent.
if claimed.rows_affected() != 1 {
return Err("Computer is busy".into()); return Err("Computer is busy".into());
} }
let ctx = adapter_context(actor, bot_id, "boot"); let ctx = adapter_context(actor, bot_id, "boot");
let home = home_path(&state.data_dir, &computer.home_key); let home = home_path(&state.data_dir, &computer.home_key);
tokio::fs::create_dir_all(&home) if let Err(error) = tokio::fs::create_dir_all(&home).await {
.await let message = error.to_string();
.map_err(|error| error.to_string())?; mark_boot_error(state, &computer_id, None).await;
let provisioned = state return Err(message);
.sandbox }
.provision( let provisioned = match tokio::time::timeout(
Duration::from_secs(120),
state.sandbox.provision(
ProvisionRequest { ProvisionRequest {
home_key: computer.home_key.clone(), home_key: computer.home_key.clone(),
home_path: home.to_string_lossy().into_owned(), home_path: home.to_string_lossy().into_owned(),
provider_ref: computer.provider_ref.clone(), provider_ref: computer.provider_ref.clone(),
}, },
&ctx, &ctx,
) ),
.await )
.map_err(|error| error.to_string())?; .await
{
Ok(Ok(provisioned)) => provisioned,
Ok(Err(error)) => {
let message = error.to_string();
mark_boot_error(state, &computer_id, None).await;
return Err(message);
}
Err(_) => {
let message = "computer provision timed out after 120 seconds".to_string();
mark_boot_error(state, &computer_id, None).await;
return Err(message);
}
};
if parse_mode(&computer.scope) == ComputerMode::Team { if parse_mode(&computer.scope) == ComputerMode::Team {
let folder = team_bot_workspace_directory(bot_id).map_err(|error| error.to_string())?; let folder = match team_bot_workspace_directory(bot_id) {
let _ = state Ok(folder) => folder,
.sandbox Err(error) => {
.execute( let message = error.to_string();
mark_boot_error(state, &computer_id, None).await;
return Err(message);
}
};
let setup = tokio::time::timeout(
Duration::from_secs(15),
state.sandbox.execute(
&provisioned, &provisioned,
CommandRequest { CommandRequest {
argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder], argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder],
@ -451,10 +561,19 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
timeout_ms: Some(10_000), timeout_ms: Some(10_000),
}, },
&ctx, &ctx,
) ),
.await; )
.await;
if let Err(error) = match setup {
Ok(result) => result.map_err(|error| error.to_string()),
Err(_) => Err("computer workspace setup timed out after 15 seconds".into()),
} {
// Workspace setup is additive; a failed mkdir should not make an
// otherwise usable desktop unavailable. The next run can retry it.
tracing::warn!("computer workspace setup failed for {bot_id}: {error}");
}
} }
sqlx::query( let running = sqlx::query(
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now() "UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now()
WHERE id = $1 AND state = 'booting'", WHERE id = $1 AND state = 'booting'",
) )
@ -464,6 +583,9 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if running.rows_affected() != 1 {
return Err("computer boot was superseded".into());
}
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -478,6 +600,20 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
Ok(status_from(bot_id, &computer, screen.as_ref(), None)) Ok(status_from(bot_id, &computer, screen.as_ref(), None))
} }
async fn mark_boot_error(state: &AppState, computer_id: &str, provider_ref: Option<&str>) {
if let Err(error) = sqlx::query(
"UPDATE computers SET state = 'error', provider_ref = COALESCE($2, provider_ref), updated_at = now()
WHERE id = $1 AND state = 'booting'",
)
.bind(computer_id)
.bind(provider_ref)
.execute(state.pool())
.await
{
tracing::error!("failed to mark computer {computer_id} boot error: {error}");
}
}
pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
@ -485,7 +621,10 @@ pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -517,18 +656,30 @@ pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let computer = state.db.get_computer(&computer_id).await.map_err(|e| e.to_string())?.unwrap(); let computer = state
.db
.get_computer(&computer_id)
.await
.map_err(|e| e.to_string())?
.unwrap();
Ok(status_from(bot_id, &computer, None, None)) Ok(status_from(bot_id, &computer, None, None))
} }
pub async fn restart(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn restart(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -537,8 +688,30 @@ pub async fn restart(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Co
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
if let Some(computer_ref) = computer_ref(&computer) { if let Some(computer_ref) = computer_ref(&computer) {
let ctx = adapter_context(actor, bot_id, "restart"); let ctx = adapter_context(actor, bot_id, "restart");
let _ = state.sandbox.stop(&computer_ref, &ctx).await; if tokio::time::timeout(
let _ = state.sandbox.destroy(&computer_ref, &ctx).await; Duration::from_secs(20),
state.sandbox.stop(&computer_ref, &ctx),
)
.await
.is_err()
{
tracing::warn!(
"computer stop timed out during restart: {}",
computer_ref.id
);
}
if tokio::time::timeout(
Duration::from_secs(20),
state.sandbox.destroy(&computer_ref, &ctx),
)
.await
.is_err()
{
tracing::warn!(
"computer destroy timed out during restart: {}",
computer_ref.id
);
}
} }
sqlx::query( sqlx::query(
"UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none', "UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none',
@ -554,14 +727,21 @@ pub async fn restart(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Co
boot(state, actor, bot_id).await boot(state, actor, bot_id).await
} }
pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(String, String), String> { pub async fn takeover(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<(String, String), String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -572,9 +752,19 @@ pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(
return Err("computer must be running".into()); return Err("computer must be running".into());
} }
let bound = ensure_bot_screen(state, actor, bot_id, &computer, None).await?; let bound = ensure_bot_screen(state, actor, bot_id, &computer, None).await?;
let screen = bound.row.ok_or_else(|| bound.gui_block.unwrap_or_else(|| "screen unavailable".into()))?; let screen = bound.row.ok_or_else(|| {
let active = state.db.active_run(bot_id).await.map_err(|error| error.to_string())?; bound
let run_status = active.as_ref().and_then(|(_, status, _)| parse_run_status(status)); .gui_block
.unwrap_or_else(|| "screen unavailable".into())
})?;
let active = state
.db
.active_run(bot_id)
.await
.map_err(|error| error.to_string())?;
let run_status = active
.as_ref()
.and_then(|(_, status, _)| parse_run_status(status));
if execution_blocks_user_takeover( if execution_blocks_user_takeover(
screen.execution_run_id.is_some(), screen.execution_run_id.is_some(),
screen.execution_lease_expires_at, screen.execution_lease_expires_at,
@ -606,6 +796,28 @@ pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let paused: Vec<(String, String)> = sqlx::query_as(
"UPDATE runs SET status = 'waiting_takeover',
checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|| jsonb_build_object('resumeAfterTakeover', true),
updated_at = now()
WHERE bot_id = $1 AND status IN ('queued','leased','running')
RETURNING id, thread_id",
)
.bind(bot_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
for (run_id, thread_id) in paused {
let _ = crate::runs::append_bot_message(
state,
&thread_id,
&run_id,
bot_id,
"你已接手操作。完成後釋放控制權,我會從目前畫面繼續。",
)
.await;
}
Ok((lease_id, expires.to_rfc3339())) Ok((lease_id, expires.to_rfc3339()))
} }
@ -690,7 +902,11 @@ pub fn user_has_control(computer: &ComputerRow, bot_id: &str) -> bool {
user_has_screen_control(computer, None, bot_id) user_has_screen_control(computer, None, bot_id)
} }
pub fn user_has_screen_control(computer: &ComputerRow, screen: Option<&ScreenRow>, bot_id: &str) -> bool { pub fn user_has_screen_control(
computer: &ComputerRow,
screen: Option<&ScreenRow>,
bot_id: &str,
) -> bool {
if let Some(screen) = screen { if let Some(screen) = screen {
return user_holds_control( return user_holds_control(
parse_holder(&screen.control_holder), parse_holder(&screen.control_holder),
@ -756,10 +972,12 @@ pub async fn idle_loop(state: AppState) {
) )
.await; .await;
} }
let _ = sqlx::query("UPDATE computers SET state = 'stopped', updated_at = now() WHERE id = $1") let _ = sqlx::query(
.bind(&computer.id) "UPDATE computers SET state = 'stopped', updated_at = now() WHERE id = $1",
.execute(state.pool()) )
.await; .bind(&computer.id)
.execute(state.pool())
.await;
} }
} }
} }
@ -775,20 +993,32 @@ pub fn computer_ref(computer: &ComputerRow) -> Option<lazyboy_control::ComputerR
}) })
} }
pub async fn current_status(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn current_status(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
let computer = state .computer_id
.ok_or_else(|| "bot has no computer".to_string())?;
let mut computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
if computer.state == "running"
&& probe_computer_container(state, actor, bot_id, &computer).await
== ContainerProbe::Missing
{
mark_computer_missing(state, &mut computer).await;
}
let screen = state let screen = state
.db .db
.get_screen(&computer.id, bot_id) .get_screen(&computer.id, bot_id)
@ -799,8 +1029,9 @@ pub async fn current_status(state: &AppState, actor: &Actor, bot_id: &str) -> Re
.as_ref() .as_ref()
.and_then(|(_, run_status, _)| parse_run_status(run_status)); .and_then(|(_, run_status, _)| parse_run_status(run_status));
let waiting_for_takeover = run_status == Some(lazyboy_contracts::RunStatus::WaitingTakeover); let waiting_for_takeover = run_status == Some(lazyboy_contracts::RunStatus::WaitingTakeover);
let busy = run_status let busy = run_status.filter(|status| {
.filter(|status| status.is_active() && *status != lazyboy_contracts::RunStatus::WaitingTakeover); status.is_active() && *status != lazyboy_contracts::RunStatus::WaitingTakeover
});
let busy_bot_name = busy.map(|_| bot.name.clone()); let busy_bot_name = busy.map(|_| bot.name.clone());
let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name); let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name);
status.takeover_requested = waiting_for_takeover; status.takeover_requested = waiting_for_takeover;

View File

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lazyboy_contracts::{ use lazyboy_contracts::{
computer_home_key, computer_scope_key, Bot, BrowserProfileMode, ComputerMode, ComputerState, Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
ControlHolder, RunStatus, SandboxKind, computer_home_key, computer_scope_key,
}; };
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
@ -85,6 +85,18 @@ pub struct BotRow {
pub memory_enabled: bool, pub memory_enabled: bool,
} }
#[derive(Debug, Clone, FromRow)]
#[allow(dead_code)]
pub struct SpaceRow {
pub id: String,
pub user_id: String,
pub name: String,
pub default_model_provider: String,
pub default_model_id: String,
pub default_model_base_url: Option<String>,
pub default_model_api_key: Option<String>,
}
impl Db { impl Db {
pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> { pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> {
let user_id = "local-user"; let user_id = "local-user";
@ -110,7 +122,66 @@ impl Db {
}) })
} }
pub async fn list_bots(&self, actor: &Actor) -> Result<Vec<(BotRow, String, ComputerRow)>, sqlx::Error> { 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
FROM spaces WHERE id = $1 AND user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(&self.pool)
.await
}
pub async fn update_workspace_model(
&self,
actor: &Actor,
provider: &str,
model_id: &str,
base_url: Option<&str>,
api_key: Option<Option<&str>>,
) -> Result<SpaceRow, sqlx::Error> {
match api_key {
Some(key) => {
sqlx::query(
"UPDATE spaces
SET default_model_provider = $3, default_model_id = $4,
default_model_base_url = $5, default_model_api_key = $6
WHERE id = $1 AND user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(provider)
.bind(model_id)
.bind(base_url)
.bind(key)
.execute(&self.pool)
.await?;
}
None => {
sqlx::query(
"UPDATE spaces
SET default_model_provider = $3, default_model_id = $4,
default_model_base_url = $5
WHERE id = $1 AND user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(provider)
.bind(model_id)
.bind(base_url)
.execute(&self.pool)
.await?;
}
}
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
}
pub async fn list_bots(
&self,
actor: &Actor,
) -> Result<Vec<(BotRow, String, ComputerRow)>, sqlx::Error> {
let bots: Vec<BotRow> = sqlx::query_as( let bots: Vec<BotRow> = sqlx::query_as(
"SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags, "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
b.pinned, b.hidden, b.group_name, b.pinned, b.hidden, b.group_name,
@ -126,18 +197,19 @@ impl Db {
.await?; .await?;
let mut out = Vec::new(); let mut out = Vec::new();
for bot in bots { for bot in bots {
let thread_id: (String,) = let thread_id: (String,) = sqlx::query_as(
sqlx::query_as( "SELECT id FROM threads
"SELECT id FROM threads
WHERE bot_id = $1 AND space_id = $2 AND user_id = $3 AND room_id IS NULL WHERE bot_id = $1 AND space_id = $2 AND user_id = $3 AND room_id IS NULL
ORDER BY updated_at DESC, created_at ASC LIMIT 1", ORDER BY updated_at DESC, created_at ASC LIMIT 1",
) )
.bind(&bot.id) .bind(&bot.id)
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(&actor.user_id) .bind(&actor.user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
let computer = self.get_computer(bot.computer_id.as_deref().unwrap_or("")).await?; let computer = self
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
.await?;
if let Some(computer) = computer { if let Some(computer) = computer {
out.push((bot, thread_id.0, computer)); out.push((bot, thread_id.0, computer));
} }
@ -145,7 +217,11 @@ impl Db {
Ok(out) Ok(out)
} }
pub async fn get_bot(&self, actor: &Actor, bot_id: &str) -> Result<Option<BotRow>, sqlx::Error> { pub async fn get_bot(
&self,
actor: &Actor,
bot_id: &str,
) -> Result<Option<BotRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags, "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
b.pinned, b.hidden, b.group_name, b.pinned, b.hidden, b.group_name,
@ -162,7 +238,10 @@ impl Db {
.await .await
} }
pub async fn get_computer(&self, computer_id: &str) -> Result<Option<ComputerRow>, sqlx::Error> { pub async fn get_computer(
&self,
computer_id: &str,
) -> Result<Option<ComputerRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
@ -246,7 +325,11 @@ impl Db {
}) })
} }
pub async fn get_screen(&self, computer_id: &str, bot_id: &str) -> Result<Option<ScreenRow>, sqlx::Error> { pub async fn get_screen(
&self,
computer_id: &str,
bot_id: &str,
) -> Result<Option<ScreenRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path, "SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path,
control_holder, control_lease_id, control_lease_expires_at, execution_run_id, control_holder, control_lease_id, control_lease_expires_at, execution_run_id,
@ -278,7 +361,10 @@ impl Db {
.await .await
} }
pub async fn active_run(&self, bot_id: &str) -> Result<Option<(String, String, String)>, sqlx::Error> { pub async fn active_run(
&self,
bot_id: &str,
) -> Result<Option<(String, String, String)>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT id, status, thread_id FROM runs "SELECT id, status, thread_id FROM runs
WHERE bot_id = $1 WHERE bot_id = $1

View File

@ -2,6 +2,7 @@ mod auth;
mod computer; mod computer;
mod db; mod db;
mod mcp; mod mcp;
mod mcp_catalog;
mod memory; mod memory;
mod rooms; mod rooms;
mod routes; mod routes;
@ -10,6 +11,7 @@ mod screen_proxy;
mod sessions; mod sessions;
mod state; mod state;
mod tools; mod tools;
mod workspace;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@ -22,9 +24,8 @@ use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
let _ = dotenvy::from_path( let _ =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"), dotenvy::from_path(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"));
);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap())) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init(); .init();
@ -38,9 +39,7 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL") let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into()); .unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into());
let state = AppState::connect(&database_url) let state = AppState::connect(&database_url).await.expect("database");
.await
.expect("database");
let actor = state.bootstrap().await.expect("bootstrap"); let actor = state.bootstrap().await.expect("bootstrap");
let mcp_state = state.clone(); let mcp_state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -59,12 +58,17 @@ async fn main() {
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into()); let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
let addr: SocketAddr = bind.parse().expect("API_BIND"); let addr: SocketAddr = bind.parse().expect("API_BIND");
if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() { if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() {
panic!("LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"); panic!(
"LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"
);
} }
let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into()); let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into());
let app = Router::new() let app = Router::new()
.route("/api/health", axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) })) .route(
"/api/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
)
.merge(auth::public_router(state.clone())) .merge(auth::public_router(state.clone()))
.merge(routes::router(state)) .merge(routes::router(state))
.fallback_service(ServeDir::new(web_dir)); .fallback_service(ServeDir::new(web_dir));

View File

@ -8,14 +8,14 @@ use axum::routing::get;
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lazyboy_contracts::{McpServer, McpTool, PatchMcpServerInput, UpsertMcpServerInput}; use lazyboy_contracts::{McpServer, McpTool, PatchMcpServerInput, UpsertMcpServerInput};
use rig_core::completion::ToolDefinition;
use rmcp::model::{CallToolRequestParams, ClientInfo, Tool}; use rmcp::model::{CallToolRequestParams, ClientInfo, Tool};
use rmcp::service::RunningService; use rmcp::service::RunningService;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::child_process::TokioChildProcess; use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
use rig_core::completion::ToolDefinition;
use rmcp::{RoleClient, ServiceExt}; use rmcp::{RoleClient, ServiceExt};
use serde_json::{json, Map, Value}; use serde_json::{Map, Value, json};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use uuid::Uuid; use uuid::Uuid;
@ -25,6 +25,7 @@ use crate::state::AppState;
type ApiError = (StatusCode, Json<Value>); type ApiError = (StatusCode, Json<Value>);
type LiveClient = RunningService<RoleClient, ClientInfo>; type LiveClient = RunningService<RoleClient, ClientInfo>;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone)] #[derive(Clone)]
pub struct McpHub { pub struct McpHub {
@ -94,7 +95,8 @@ impl McpHub {
map map
} }
}; };
let params = CallToolRequestParams::new(tool.name.clone()).with_arguments(arguments); let params =
CallToolRequestParams::new(tool.name.clone()).with_arguments(arguments);
let result = entry let result = entry
.client .client
.call_tool(params) .call_tool(params)
@ -124,7 +126,10 @@ impl McpHub {
let client = match connect_client(row).await { let client = match connect_client(row).await {
Ok(client) => client, Ok(client) => client,
Err(error) => { Err(error) => {
self.errors.lock().await.insert(row.id.clone(), error.clone()); self.errors
.lock()
.await
.insert(row.id.clone(), error.clone());
return Err(error); return Err(error);
} }
}; };
@ -132,7 +137,10 @@ impl McpHub {
Ok(raw) => raw, Ok(raw) => raw,
Err(error) => { Err(error) => {
let message = error.to_string(); let message = error.to_string();
self.errors.lock().await.insert(row.id.clone(), message.clone()); self.errors
.lock()
.await
.insert(row.id.clone(), message.clone());
return Err(message); return Err(message);
} }
}; };
@ -219,17 +227,19 @@ fn slugify(name: &str) -> String {
slug = slug.replace("__", "_"); slug = slug.replace("__", "_");
} }
let slug = slug.trim_matches('_').chars().take(24).collect::<String>(); let slug = slug.trim_matches('_').chars().take(24).collect::<String>();
if slug.is_empty() { if slug.is_empty() { "mcp".into() } else { slug }
"mcp".into()
} else {
slug
}
} }
fn exposed_name_for(slug: &str, tool: &str) -> String { fn exposed_name_for(slug: &str, tool: &str) -> String {
let tool: String = tool let tool: String = tool
.chars() .chars()
.map(|ch| if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { ch } else { '_' }) .map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
ch
} else {
'_'
}
})
.collect(); .collect();
format!("mcp_{slug}_{tool}") format!("mcp_{slug}_{tool}")
} }
@ -253,11 +263,12 @@ async fn connect_client(row: &McpRow) -> Result<LiveClient, String> {
cmd.env(key, text); cmd.env(key, text);
} }
} }
let transport = TokioChildProcess::new(cmd).map_err(|error| error.to_string())?; let transport = TokioChildProcess::new(cmd)
.map_err(|error| humanize_mcp_error(Some(command), &error.to_string()))?;
ClientInfo::default() ClientInfo::default()
.serve(transport) .serve(transport)
.await .await
.map_err(|error| error.to_string()) .map_err(|error| humanize_mcp_error(Some(command), &error.to_string()))
} }
"http" | "sse" => { "http" | "sse" => {
let url = row let url = row
@ -292,12 +303,29 @@ async fn connect_client(row: &McpRow) -> Result<LiveClient, String> {
ClientInfo::default() ClientInfo::default()
.serve(transport) .serve(transport)
.await .await
.map_err(|error| error.to_string()) .map_err(|error| humanize_mcp_error(None, &error.to_string()))
} }
other => Err(format!("不支援的 transport{other}")), other => Err(format!("不支援的 transport{other}")),
} }
} }
fn humanize_mcp_error(command: Option<&str>, error: &str) -> String {
let lower = error.to_ascii_lowercase();
if lower.contains("auth required") || lower.contains("unauthorized") || lower.contains("401") {
return "這個 MCP 需要有效金鑰才能連線。請填 token 後再試。".into();
}
if lower.contains("no such file or directory") {
return match command {
Some(cmd) => format!("找不到指令 `{cmd}`。API 容器沒有這個執行檔。"),
None => "找不到 MCP 指令。".into(),
};
}
if lower.contains("timed out") || lower.contains("timeout") {
return "連線逾時。遠端服務沒回應,或第一次下載套件太久。".into();
}
error.to_string()
}
#[derive(Clone)] #[derive(Clone)]
pub struct McpRow { pub struct McpRow {
pub id: String, pub id: String,
@ -394,7 +422,11 @@ async fn load_rows(pool: &sqlx::PgPool, actor: &Actor) -> Result<Vec<McpRow>, sq
Ok(rows.into_iter().map(row_from).collect()) Ok(rows.into_iter().map(row_from).collect())
} }
async fn load_row(pool: &sqlx::PgPool, actor: &Actor, id: &str) -> Result<Option<McpRow>, sqlx::Error> { async fn load_row(
pool: &sqlx::PgPool,
actor: &Actor,
id: &str,
) -> Result<Option<McpRow>, sqlx::Error> {
let row: Option<RowTuple> = sqlx::query_as( let row: Option<RowTuple> = sqlx::query_as(
"SELECT id, name, transport, command, args, env, url, headers, enabled, created_at, updated_at "SELECT id, name, transport, command, args, env, url, headers, enabled, created_at, updated_at
FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3", FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3",
@ -414,7 +446,11 @@ pub fn router() -> Router<AppState> {
"/api/mcp-servers/{id}", "/api/mcp-servers/{id}",
get(get_server).patch(update_server).delete(delete_server), get(get_server).patch(update_server).delete(delete_server),
) )
.route("/api/mcp-servers/{id}/reconnect", axum::routing::post(reconnect_server)) .route(
"/api/mcp-servers/{id}/reconnect",
axum::routing::post(reconnect_server),
)
.merge(crate::mcp_catalog::router())
} }
fn internal(message: String) -> ApiError { fn internal(message: String) -> ApiError {
@ -429,6 +465,14 @@ fn bad(message: &str) -> ApiError {
(StatusCode::BAD_REQUEST, Json(json!({"message": message}))) (StatusCode::BAD_REQUEST, Json(json!({"message": message})))
} }
async fn rollback_server(state: &AppState, id: &str) {
state.mcp.forget(id).await;
let _ = sqlx::query("DELETE FROM mcp_servers WHERE id=$1")
.bind(id)
.execute(state.pool())
.await;
}
async fn actor(state: &AppState) -> Result<Actor, ApiError> { async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state state
.bootstrap() .bootstrap()
@ -484,7 +528,7 @@ async fn create_server(
validate_input(&input)?; validate_input(&input)?;
let actor = actor(&state).await?; let actor = actor(&state).await?;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let name = input.name.trim().chars().take(40).collect::<String>(); let name = input.name.trim().chars().take(80).collect::<String>();
sqlx::query( sqlx::query(
"INSERT INTO mcp_servers (id,space_id,user_id,name,transport,command,args,env,url,headers,enabled) "INSERT INTO mcp_servers (id,space_id,user_id,name,transport,command,args,env,url,headers,enabled)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
@ -515,27 +559,32 @@ async fn create_server(
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.ok_or_else(|| internal("missing row".into()))?; .ok_or_else(|| internal("missing row".into()))?;
let mut status = if row.enabled { "disconnected" } else { "disabled" }.to_string(); let mut status = if row.enabled {
let mut error = None; "disconnected"
} else {
"disabled"
}
.to_string();
let mut tools = Vec::new(); let mut tools = Vec::new();
if row.enabled { if row.enabled {
match tokio::time::timeout(Duration::from_secs(25), state.mcp.connect_row(&row)).await { match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => { Ok(Ok(connected)) => {
status = "connected".into(); status = "connected".into();
tools = connected; tools = connected;
} }
Ok(Err(message)) => { Ok(Err(message)) => {
error = Some(message); rollback_server(&state, &id).await;
return Err((StatusCode::BAD_GATEWAY, Json(json!({"message": message}))));
} }
Err(_) => { Err(_) => {
error = Some("連線逾時25 秒)".into()); rollback_server(&state, &id).await;
return Err(bad("連線逾時60 秒)"));
} }
} }
} }
row.last_error = error.clone();
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(row.into_server(status, error, tools)), Json(row.into_server(status, None, tools)),
)) ))
} }
@ -626,17 +675,22 @@ async fn update_server(
} else { } else {
state.mcp.forget(&id).await; state.mcp.forget(&id).await;
} }
let mut status = if row.enabled { "disconnected" } else { "disabled" }.to_string(); let mut status = if row.enabled {
"disconnected"
} else {
"disabled"
}
.to_string();
let mut error = None; let mut error = None;
let mut tools = Vec::new(); let mut tools = Vec::new();
if row.enabled { if row.enabled {
match tokio::time::timeout(Duration::from_secs(25), state.mcp.connect_row(&row)).await { match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => { Ok(Ok(connected)) => {
status = "connected".into(); status = "connected".into();
tools = connected; tools = connected;
} }
Ok(Err(message)) => error = Some(message), Ok(Err(message)) => error = Some(message),
Err(_) => error = Some("連線逾時(25 秒)".into()), Err(_) => error = Some("連線逾時(60 秒)".into()),
} }
} }
row.last_error = error.clone(); row.last_error = error.clone();
@ -675,18 +729,22 @@ async fn reconnect_server(
state.mcp.disconnect(&id).await; state.mcp.disconnect(&id).await;
return Ok(Json(row.into_server("disabled".into(), None, Vec::new()))); return Ok(Json(row.into_server("disabled".into(), None, Vec::new())));
} }
match tokio::time::timeout(Duration::from_secs(25), state.mcp.connect_row(&row)).await { match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(tools)) => Ok(Json(row.into_server("connected".into(), None, tools))), Ok(Ok(tools)) => Ok(Json(row.into_server("connected".into(), None, tools))),
Ok(Err(message)) => { Ok(Err(message)) => {
state.mcp.disconnect(&id).await; state.mcp.disconnect(&id).await;
row.last_error = Some(message.clone()); row.last_error = Some(message.clone());
Ok(Json(row.into_server("disconnected".into(), Some(message), Vec::new()))) Ok(Json(row.into_server(
"disconnected".into(),
Some(message),
Vec::new(),
)))
} }
Err(_) => { Err(_) => {
state.mcp.disconnect(&id).await; state.mcp.disconnect(&id).await;
Ok(Json(row.into_server( Ok(Json(row.into_server(
"disconnected".into(), "disconnected".into(),
Some("連線逾時(25 秒)".into()), Some("連線逾時(60 秒)".into()),
Vec::new(), Vec::new(),
))) )))
} }

View File

@ -0,0 +1,367 @@
use axum::extract::Query;
use axum::http::StatusCode;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::AppState;
const REGISTRY_URL: &str = "https://registry.modelcontextprotocol.io/v0/servers";
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntry {
pub id: String,
pub title: String,
pub description: String,
pub transport: String,
pub command: Option<String>,
pub args: Vec<String>,
pub url: Option<String>,
pub env_keys: Vec<SecretField>,
pub header_keys: Vec<SecretField>,
pub source: &'static str,
pub remote: bool,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SecretField {
pub name: String,
pub required: bool,
pub secret: bool,
pub hint: String,
}
#[derive(Deserialize)]
pub struct CatalogQuery {
#[serde(default)]
pub q: String,
}
pub fn router() -> Router<AppState> {
Router::new().route("/api/mcp-catalog", axum::routing::get(list_catalog))
}
async fn list_catalog(Query(query): Query<CatalogQuery>) -> Result<Json<Value>, StatusCode> {
let q = query.q.trim().to_ascii_lowercase();
let mut items = featured();
if !q.is_empty() {
items.retain(|item| {
item.title.to_ascii_lowercase().contains(&q)
|| item.description.to_ascii_lowercase().contains(&q)
|| item.id.to_ascii_lowercase().contains(&q)
});
if let Ok(remote) = fetch_registry(&query.q).await {
for entry in remote {
if items
.iter()
.any(|item| item.id == entry.id || item.title == entry.title)
{
continue;
}
items.push(entry);
}
}
}
Ok(Json(json!({ "servers": items })))
}
fn featured() -> Vec<CatalogEntry> {
vec![
remote(
"context7",
"Context7",
"即時函式庫文件與程式範例,寫程式時查最新 API。",
"https://mcp.context7.com/mcp",
),
remote(
"deepwiki",
"DeepWiki",
"讀 GitHub 專案的 Wiki 風格說明。",
"https://mcp.deepwiki.com/mcp",
),
remote(
"cloudflare",
"Cloudflare",
"搜尋 Cloudflare 文件Workers、DNS、R2 等)。",
"https://docs.mcp.cloudflare.com/mcp",
),
remote_auth(
"linear",
"Linear",
"查詢與建立 Linear issue、專案與留言。",
"https://mcp.linear.app/mcp",
&[("Authorization", true, "Linear API key")],
),
remote_auth(
"sentry",
"Sentry",
"讀取錯誤、issue 與專案狀態。",
"https://mcp.sentry.dev/mcp",
&[("Authorization", true, "Sentry auth token")],
),
remote_auth(
"github",
"GitHub",
"搜尋 repo、issue、PR。",
"https://api.githubcopilot.com/mcp/",
&[("Authorization", true, "GitHub PATrepo 權限)")],
),
stdio(
"notion",
"Notion",
"搜尋與讀取 Notion 頁面。",
"npx",
&["-y", "@notionhq/notion-mcp-server"],
&[("NOTION_TOKEN", true, "Notion integration token")],
),
stdio(
"fetch",
"Fetch",
"抓網頁內容給 Agent 讀。",
"python3",
&["-m", "mcp_server_fetch"],
&[],
),
stdio(
"memory",
"Memory",
"給 Agent 一個可持久的知識圖譜。",
"npx",
&["-y", "@modelcontextprotocol/server-memory"],
&[],
),
stdio(
"sequential-thinking",
"Sequential Thinking",
"把推理拆成步驟,適合複雜規劃。",
"npx",
&["-y", "@modelcontextprotocol/server-sequential-thinking"],
&[],
),
]
}
fn remote(id: &str, title: &str, description: &str, url: &str) -> CatalogEntry {
CatalogEntry {
id: id.into(),
title: title.into(),
description: description.into(),
transport: "http".into(),
command: None,
args: Vec::new(),
url: Some(url.into()),
env_keys: Vec::new(),
header_keys: Vec::new(),
source: "featured",
remote: true,
}
}
fn remote_auth(
id: &str,
title: &str,
description: &str,
url: &str,
headers: &[(&str, bool, &str)],
) -> CatalogEntry {
let mut entry = remote(id, title, description, url);
entry.header_keys = headers
.iter()
.map(|(name, required, hint)| SecretField {
name: (*name).into(),
required: *required,
secret: true,
hint: (*hint).into(),
})
.collect();
entry
}
fn stdio(
id: &str,
title: &str,
description: &str,
command: &str,
args: &[&str],
env: &[(&str, bool, &str)],
) -> CatalogEntry {
CatalogEntry {
id: id.into(),
title: title.into(),
description: description.into(),
transport: "stdio".into(),
command: Some(command.into()),
args: args.iter().map(|value| (*value).to_string()).collect(),
url: None,
env_keys: env
.iter()
.map(|(name, required, hint)| SecretField {
name: (*name).into(),
required: *required,
secret: true,
hint: (*hint).into(),
})
.collect(),
header_keys: Vec::new(),
source: "featured",
remote: false,
}
}
async fn fetch_registry(search: &str) -> Result<Vec<CatalogEntry>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.map_err(|error| error.to_string())?;
let response = client
.get(REGISTRY_URL)
.query(&[("search", search), ("limit", "24")])
.send()
.await
.map_err(|error| error.to_string())?;
if !response.status().is_success() {
return Err(format!("registry {}", response.status()));
}
let body: Value = response.json().await.map_err(|error| error.to_string())?;
let servers = body
.get("servers")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Ok(servers.iter().filter_map(parse_registry_item).collect())
}
fn parse_registry_item(item: &Value) -> Option<CatalogEntry> {
let server = item.get("server").unwrap_or(item);
let id = server.get("name")?.as_str()?.to_string();
let title = server
.get("title")
.and_then(Value::as_str)
.unwrap_or_else(|| id.rsplit('/').next().unwrap_or(&id))
.to_string();
let description = server
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if let Some(remote) = server
.get("remotes")
.and_then(Value::as_array)
.and_then(|items| {
items
.iter()
.find(|item| item.get("url").and_then(Value::as_str).is_some())
})
{
let url = remote.get("url")?.as_str()?.to_string();
let transport = match remote
.get("type")
.and_then(Value::as_str)
.unwrap_or("streamable-http")
{
"sse" => "sse",
_ => "http",
};
let header_keys = remote
.get("headers")
.and_then(Value::as_array)
.map(|headers| {
headers
.iter()
.filter_map(|header| {
Some(SecretField {
name: header.get("name")?.as_str()?.to_string(),
required: header
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(true),
secret: header
.get("isSecret")
.and_then(Value::as_bool)
.unwrap_or(true),
hint: header
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
})
.collect()
})
.unwrap_or_default();
return Some(CatalogEntry {
id,
title,
description,
transport: transport.into(),
command: None,
args: Vec::new(),
url: Some(url),
env_keys: Vec::new(),
header_keys,
source: "registry",
remote: true,
});
}
let package = server.get("packages").and_then(Value::as_array)?.first()?;
let identifier = package.get("identifier")?.as_str()?;
let registry_type = package
.get("registryType")
.and_then(Value::as_str)
.unwrap_or("npm");
let (command, args) = match registry_type {
"npm" => ("npx".into(), vec!["-y".into(), identifier.into()]),
"pypi" => ("uvx".into(), vec![identifier.into()]),
_ => return None,
};
let extra = package
.get("runtimeArguments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|arg| arg.get("value").and_then(Value::as_str).map(str::to_string));
let mut args = args;
args.extend(extra);
let env_keys = package
.get("environmentVariables")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
Some(SecretField {
name: item.get("name")?.as_str()?.to_string(),
required: item
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(false),
secret: item
.get("isSecret")
.and_then(Value::as_bool)
.unwrap_or(true),
hint: item
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
})
.collect()
})
.unwrap_or_default();
Some(CatalogEntry {
id,
title,
description,
transport: "stdio".into(),
command: Some(command),
args,
url: None,
env_keys,
header_keys: Vec::new(),
source: "registry",
remote: false,
})
}

View File

@ -8,7 +8,7 @@ use axum::{Json, Router};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions}; use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
@ -94,7 +94,9 @@ impl MemoryService {
let model = self.model.clone(); let model = self.model.clone();
let cache_dir = self.cache_dir.clone(); let cache_dir = self.cache_dir.clone();
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || {
let mut state = model.lock().map_err(|_| "embedding model lock poisoned".to_string())?; let mut state = model
.lock()
.map_err(|_| "embedding model lock poisoned".to_string())?;
if matches!(*state, ModelState::Uninitialized) { if matches!(*state, ModelState::Uninitialized) {
let options = TextInitOptions::new(EmbeddingModel::AllMiniLML6V2) let options = TextInitOptions::new(EmbeddingModel::AllMiniLML6V2)
.with_cache_dir(cache_dir) .with_cache_dir(cache_dir)
@ -113,7 +115,9 @@ impl MemoryService {
let mut values = embedding let mut values = embedding
.embed(vec![text], None) .embed(vec![text], None)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let value = values.pop().ok_or_else(|| "FastEmbed returned no vector".to_string())?; let value = values
.pop()
.ok_or_else(|| "FastEmbed returned no vector".to_string())?;
if value.len() != EMBEDDING_DIMENSION { if value.len() != EMBEDDING_DIMENSION {
return Err(format!( return Err(format!(
"embedding dimension {} does not match schema {}", "embedding dimension {} does not match schema {}",
@ -262,7 +266,11 @@ impl MemoryService {
) -> Result<Option<MemoryItem>, String> { ) -> Result<Option<MemoryItem>, String> {
let content = validate_content(&input.content)?; let content = validate_content(&input.content)?;
validate_importance(input.importance)?; validate_importance(input.importance)?;
let vector = self.embed(content.clone()).await.as_ref().map(vector_literal); let vector = self
.embed(content.clone())
.await
.as_ref()
.map(vector_literal);
let mut tx = pool.begin().await.map_err(|error| error.to_string())?; let mut tx = pool.begin().await.map_err(|error| error.to_string())?;
let item: Option<MemoryItem> = sqlx::query_as( let item: Option<MemoryItem> = sqlx::query_as(
"UPDATE memory_items SET content=$1,importance=$2,embedding=$3::vector, "UPDATE memory_items SET content=$1,importance=$2,embedding=$3::vector,
@ -316,12 +324,7 @@ impl MemoryService {
Ok(item.is_some()) Ok(item.is_some())
} }
pub async fn clear( pub async fn clear(&self, pool: &PgPool, actor: &Actor, bot_id: &str) -> Result<u64, String> {
&self,
pool: &PgPool,
actor: &Actor,
bot_id: &str,
) -> Result<u64, String> {
let ids: Vec<Uuid> = sqlx::query_scalar( let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT id FROM memory_items "SELECT id FROM memory_items
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND deleted_at IS NULL", WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND deleted_at IS NULL",
@ -377,7 +380,11 @@ async fn insert_revision(
fn vector_literal(vector: &Vec<f32>) -> String { fn vector_literal(vector: &Vec<f32>) -> String {
format!( format!(
"[{}]", "[{}]",
vector.iter().map(f32::to_string).collect::<Vec<_>>().join(",") vector
.iter()
.map(f32::to_string)
.collect::<Vec<_>>()
.join(",")
) )
} }
@ -395,7 +402,9 @@ pub fn validate_content(value: &str) -> Result<String, String> {
return Err("memory content must contain 1 to 16000 bytes".into()); return Err("memory content must contain 1 to 16000 bytes".into());
} }
if looks_like_secret(content) { if looks_like_secret(content) {
return Err("refusing to store a password, token, private key, or other obvious secret".into()); return Err(
"refusing to store a password, token, private key, or other obvious secret".into(),
);
} }
Ok(content.to_string()) Ok(content.to_string())
} }
@ -403,9 +412,19 @@ pub fn validate_content(value: &str) -> Result<String, String> {
pub fn looks_like_secret(value: &str) -> bool { pub fn looks_like_secret(value: &str) -> bool {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
let labels = [ let labels = [
"password=", "password:", "passwd=", "passwd:", "api_key=", "api key:", "password=",
"apikey=", "access_token=", "access token:", "refresh_token=", "bearer ", "password:",
"client_secret=", "client secret:", "passwd=",
"passwd:",
"api_key=",
"api key:",
"apikey=",
"access_token=",
"access token:",
"refresh_token=",
"bearer ",
"client_secret=",
"client secret:",
]; ];
labels.iter().any(|label| lower.contains(label)) labels.iter().any(|label| lower.contains(label))
|| lower.contains("-----begin private key-----") || lower.contains("-----begin private key-----")
@ -441,60 +460,126 @@ pub fn memory_block(items: &[MemoryItem], budget: usize) -> String {
fn env_bool(name: &str, default: bool) -> bool { fn env_bool(name: &str, default: bool) -> bool {
std::env::var(name) std::env::var(name)
.ok() .ok()
.map(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no")) .map(|value| {
!matches!(
value.to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(default) .unwrap_or(default)
} }
fn env_usize(name: &str, default: usize) -> usize { fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default) std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/api/bots/{bot_id}/memories", get(list_memories).post(create_memory).delete(clear_memories)) .route(
.route("/api/bots/{bot_id}/memories/{memory_id}", delete(delete_memory).patch(update_memory)) "/api/bots/{bot_id}/memories",
get(list_memories)
.post(create_memory)
.delete(clear_memories),
)
.route(
"/api/bots/{bot_id}/memories/{memory_id}",
delete(delete_memory).patch(update_memory),
)
} }
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> { async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
let actor = state.bootstrap().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let actor = state
state.db.get_bot(&actor, bot_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state
.db
.get_bot(&actor, bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
Ok(actor) Ok(actor)
} }
async fn list_memories(State(state): State<AppState>, Path(bot_id): Path<String>) -> Result<Json<Vec<MemoryItem>>, StatusCode> { async fn list_memories(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
state.memory.list(state.pool(), &actor, &bot_id).await.map(Json) state
.memory
.list(state.pool(), &actor, &bot_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
async fn create_memory(State(state): State<AppState>, Path(bot_id): Path<String>, Json(input): Json<CreateMemoryInput>) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> { async fn create_memory(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(input): Json<CreateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
state.memory.remember(state.pool(), &actor, &bot_id, input).await.map(Json).map_err(api_error) state
.memory
.remember(state.pool(), &actor, &bot_id, input)
.await
.map(Json)
.map_err(api_error)
} }
async fn update_memory(State(state): State<AppState>, Path((bot_id, memory_id)): Path<(String, Uuid)>, Json(input): Json<UpdateMemoryInput>) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> { async fn update_memory(
State(state): State<AppState>,
Path((bot_id, memory_id)): Path<(String, Uuid)>,
Json(input): Json<UpdateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
state.memory.update(state.pool(), &actor, &bot_id, memory_id, input).await state
.map_err(api_error)?.map(Json).ok_or_else(|| api_status(StatusCode::NOT_FOUND)) .memory
.update(state.pool(), &actor, &bot_id, memory_id, input)
.await
.map_err(api_error)?
.map(Json)
.ok_or_else(|| api_status(StatusCode::NOT_FOUND))
} }
async fn delete_memory(State(state): State<AppState>, Path((bot_id, memory_id)): Path<(String, Uuid)>) -> Result<Json<Value>, (StatusCode, Json<Value>)> { async fn delete_memory(
State(state): State<AppState>,
Path((bot_id, memory_id)): Path<(String, Uuid)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
if !state.memory.forget(state.pool(), &actor, &bot_id, memory_id).await.map_err(api_error)? { if !state
.memory
.forget(state.pool(), &actor, &bot_id, memory_id)
.await
.map_err(api_error)?
{
return Err(api_status(StatusCode::NOT_FOUND)); return Err(api_status(StatusCode::NOT_FOUND));
} }
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
async fn clear_memories(State(state): State<AppState>, Path(bot_id): Path<String>) -> Result<Json<Value>, (StatusCode, Json<Value>)> { async fn clear_memories(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
let deleted = state.memory.clear(state.pool(), &actor, &bot_id).await.map_err(api_error)?; let deleted = state
.memory
.clear(state.pool(), &actor, &bot_id)
.await
.map_err(api_error)?;
Ok(Json(json!({"ok":true,"deleted":deleted}))) Ok(Json(json!({"ok":true,"deleted":deleted})))
} }
fn api_status(status: StatusCode) -> (StatusCode, Json<Value>) { fn api_status(status: StatusCode) -> (StatusCode, Json<Value>) {
(status, Json(json!({"message":status.canonical_reason().unwrap_or("request failed")}))) (
status,
Json(json!({"message":status.canonical_reason().unwrap_or("request failed")})),
)
} }
fn api_error(error: String) -> (StatusCode, Json<Value>) { fn api_error(error: String) -> (StatusCode, Json<Value>) {
@ -504,7 +589,7 @@ fn api_error(error: String) -> (StatusCode, Json<Value>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{looks_like_secret, memory_block, MemoryItem, MemoryService, ModelState}; use super::{MemoryItem, MemoryService, ModelState, looks_like_secret, memory_block};
use crate::db::Actor; use crate::db::Actor;
use chrono::Utc; use chrono::Utc;
use std::path::PathBuf; use std::path::PathBuf;
@ -513,23 +598,35 @@ mod tests {
fn item(content: &str) -> MemoryItem { fn item(content: &str) -> MemoryItem {
MemoryItem { MemoryItem {
id: Uuid::new_v4(), session_id: None, source_run_id: None, source_message_id: None, id: Uuid::new_v4(),
content: content.into(), importance: 0.5, revision: 1, session_id: None,
created_at: Utc::now(), updated_at: Utc::now(), deleted_at: None, source_run_id: None,
source_message_id: None,
content: content.into(),
importance: 0.5,
revision: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
} }
} }
#[test] #[test]
fn secret_guard_rejects_obvious_credentials_without_blocking_normal_preferences() { fn secret_guard_rejects_obvious_credentials_without_blocking_normal_preferences() {
assert!(looks_like_secret("password=hunter2")); assert!(looks_like_secret("password=hunter2"));
assert!(looks_like_secret("Authorization: Bearer abcdefghijklmnopqrstuvwxyz")); assert!(looks_like_secret(
"Authorization: Bearer abcdefghijklmnopqrstuvwxyz"
));
assert!(looks_like_secret("-----BEGIN PRIVATE KEY-----")); assert!(looks_like_secret("-----BEGIN PRIVATE KEY-----"));
assert!(!looks_like_secret("I prefer passkeys instead of passwords")); assert!(!looks_like_secret("I prefer passkeys instead of passwords"));
} }
#[test] #[test]
fn durable_block_is_bounded_and_marks_memory_as_data() { fn durable_block_is_bounded_and_marks_memory_as_data() {
let block = memory_block(&[item("prefers concise replies"), item(&"x".repeat(1000))], 220); let block = memory_block(
&[item("prefers concise replies"), item(&"x".repeat(1000))],
220,
);
assert!(block.len() <= 220); assert!(block.len() <= 220);
assert!(block.contains("DATA ONLY")); assert!(block.contains("DATA ONLY"));
assert!(block.contains("prefers concise replies")); assert!(block.contains("prefers concise replies"));
@ -539,31 +636,62 @@ mod tests {
#[sqlx::test(migrations = "../../migrations")] #[sqlx::test(migrations = "../../migrations")]
async fn database_enforces_agent_scope_and_queries_do_not_leak(pool: sqlx::PgPool) { async fn database_enforces_agent_scope_and_queries_do_not_leak(pool: sqlx::PgPool) {
sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')") sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')") sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
for bot in ["a", "b"] { for bot in ["a", "b"] {
sqlx::query("INSERT INTO bots(id,space_id,user_id,name) VALUES ($1,'s','u',$1)") sqlx::query("INSERT INTO bots(id,space_id,user_id,name) VALUES ($1,'s','u',$1)")
.bind(bot).execute(&pool).await.unwrap(); .bind(bot)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ($1,'s','u',$2)") sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ($1,'s','u',$2)")
.bind(format!("thread-{bot}")).bind(bot).execute(&pool).await.unwrap(); .bind(format!("thread-{bot}"))
.bind(bot)
.execute(&pool)
.await
.unwrap();
} }
let cross_scope = sqlx::query( let cross_scope = sqlx::query(
"INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content) "INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content)
VALUES ($1,'s','u','a','thread-b','must fail')", VALUES ($1,'s','u','a','thread-b','must fail')",
).bind(Uuid::new_v4()).execute(&pool).await; )
.bind(Uuid::new_v4())
.execute(&pool)
.await;
assert!(cross_scope.is_err()); assert!(cross_scope.is_err());
sqlx::query( sqlx::query(
"INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content) "INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content)
VALUES ($1,'s','u','a','thread-a','only a'),($2,'s','u','b','thread-b','only b')", VALUES ($1,'s','u','a','thread-a','only a'),($2,'s','u','b','thread-b','only b')",
).bind(Uuid::new_v4()).bind(Uuid::new_v4()).execute(&pool).await.unwrap(); )
.bind(Uuid::new_v4())
.bind(Uuid::new_v4())
.execute(&pool)
.await
.unwrap();
let service = MemoryService { let service = MemoryService {
enabled: false, top_k: 8, byte_budget: 6000, cache_dir: PathBuf::new(), enabled: false,
top_k: 8,
byte_budget: 6000,
cache_dir: PathBuf::new(),
model: Arc::new(Mutex::new(ModelState::Unavailable)), model: Arc::new(Mutex::new(ModelState::Unavailable)),
}; };
let rows = service.list(&pool, &Actor { user_id: "u".into(), space_id: "s".into() }, "a") let rows = service
.await.unwrap(); .list(
&pool,
&Actor {
user_id: "u".into(),
space_id: "s".into(),
},
"a",
)
.await
.unwrap();
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
assert_eq!(rows[0].content, "only a"); assert_eq!(rows[0].content, "only a");
} }

View File

@ -4,8 +4,10 @@ use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::routing::get; use axum::routing::get;
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::{CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session}; use lazyboy_contracts::{
use serde_json::{json, Value}; CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session,
};
use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
use crate::db::Actor; use crate::db::Actor;
@ -18,7 +20,10 @@ pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/api/rooms", get(list_rooms).post(create_room)) .route("/api/rooms", get(list_rooms).post(create_room))
.route("/api/rooms/{id}", get(get_room).delete(delete_room)) .route("/api/rooms/{id}", get(get_room).delete(delete_room))
.route("/api/rooms/{id}/sessions", get(list_sessions).post(create_session)) .route(
"/api/rooms/{id}/sessions",
get(list_sessions).post(create_session),
)
.route("/api/rooms/{id}/status", get(room_status)) .route("/api/rooms/{id}/status", get(room_status))
} }
@ -59,7 +64,11 @@ async fn members_for(state: &AppState, room_id: &str) -> Result<Vec<RoomMember>,
Ok(rows.into_iter().map(member_from_row).collect()) Ok(rows.into_iter().map(member_from_row).collect())
} }
async fn room_from_id(state: &AppState, actor: &Actor, id: &str) -> Result<Option<Room>, sqlx::Error> { async fn room_from_id(
state: &AppState,
actor: &Actor,
id: &str,
) -> Result<Option<Room>, sqlx::Error> {
let row: Option<(String, String, Option<chrono::DateTime<chrono::Utc>>, Option<String>, i64)> = let row: Option<(String, String, Option<chrono::DateTime<chrono::Utc>>, Option<String>, i64)> =
sqlx::query_as( sqlx::query_as(
"SELECT r.id, r.name, "SELECT r.id, r.name,
@ -145,13 +154,20 @@ async fn create_room(
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
let Some(row) = exists else { let Some(row) = exists else {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"找不到機器人"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"找不到機器人"})),
));
}; };
members.push(member_from_row(row)); members.push(member_from_row(row));
} }
let room_id = Uuid::new_v4().to_string(); let room_id = Uuid::new_v4().to_string();
let host_id = members[0].id.clone(); let host_id = members[0].id.clone();
let mut tx = state.pool().begin().await.map_err(|error| internal(error.to_string()))?; let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
sqlx::query("INSERT INTO rooms (id,space_id,user_id,name) VALUES ($1,$2,$3,$4)") sqlx::query("INSERT INTO rooms (id,space_id,user_id,name) VALUES ($1,$2,$3,$4)")
.bind(&room_id) .bind(&room_id)
.bind(&actor.space_id) .bind(&actor.space_id)
@ -180,7 +196,9 @@ async fn create_room(
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
tx.commit().await.map_err(|error| internal(error.to_string()))?; tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(Room { Json(Room {
@ -194,16 +212,27 @@ async fn create_room(
)) ))
} }
async fn get_room(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Room>, ApiError> { async fn get_room(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Room>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
room_from_id(&state, &actor, &id) room_from_id(&state, &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.map(Json) .map(Json)
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})))) .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
)
})
} }
async fn delete_room(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, ApiError> { async fn delete_room(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3") let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id) .bind(&id)
@ -213,7 +242,10 @@ async fn delete_room(State(state): State<AppState>, Path(id): Path<String>) -> R
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
if deleted.rows_affected() == 0 { if deleted.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -228,7 +260,10 @@ async fn list_sessions(
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.is_none() .is_none()
{ {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
} }
let rows = sqlx::query_as( let rows = sqlx::query_as(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq, "SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
@ -256,12 +291,17 @@ async fn create_session(
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
else { else {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
}; };
let host = room let host = room.members.first().ok_or_else(|| {
.members (
.first() StatusCode::BAD_REQUEST,
.ok_or_else(|| (StatusCode::BAD_REQUEST, Json(json!({"message":"群組沒有成員"}))))?; Json(json!({"message":"群組沒有成員"})),
)
})?;
let title = normalized_title(&input.title); let title = normalized_title(&input.title);
let row = sqlx::query_as( let row = sqlx::query_as(
"INSERT INTO threads (id, space_id, bot_id, user_id, title, room_id) "INSERT INTO threads (id, space_id, bot_id, user_id, title, room_id)
@ -291,7 +331,10 @@ async fn room_status(
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.is_none() .is_none()
{ {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
} }
let rows: Vec<(String, String, String, String)> = sqlx::query_as( let rows: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT DISTINCT b.id, b.name, b.avatar_color, b.avatar_shape "SELECT DISTINCT b.id, b.name, b.avatar_color, b.avatar_shape

View File

@ -5,10 +5,10 @@ use axum::routing::{any, delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput, UpdateBotInput}; use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput, UpdateBotInput};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use crate::computer; use crate::computer;
use crate::db::{parse_mode, Actor}; use crate::db::{Actor, parse_mode};
use crate::state::AppState; use crate::state::AppState;
pub fn router(state: AppState) -> Router { pub fn router(state: AppState) -> Router {
@ -17,12 +17,19 @@ pub fn router(state: AppState) -> Router {
.merge(crate::memory::router()) .merge(crate::memory::router())
.merge(crate::rooms::router()) .merge(crate::rooms::router())
.merge(crate::mcp::router()) .merge(crate::mcp::router())
.merge(crate::workspace::router())
.route("/api/bots", get(list_bots).post(create_bot)) .route("/api/bots", get(list_bots).post(create_bot))
.route("/api/bots/{id}", get(get_bot).patch(update_bot).delete(delete_bot)) .route(
"/api/bots/{id}",
get(get_bot).patch(update_bot).delete(delete_bot),
)
.route("/api/bots/{id}/stop", post(stop_task)) .route("/api/bots/{id}/stop", post(stop_task))
.route("/api/bots/{id}/inbox", post(update_inbox)) .route("/api/bots/{id}/inbox", post(update_inbox))
.route("/api/environments/{id}", delete(delete_environment)) .route("/api/environments/{id}", delete(delete_environment))
.route("/api/bots/{id}/messages", get(list_messages).post(send_message)) .route(
"/api/bots/{id}/messages",
get(list_messages).post(send_message),
)
.route("/api/computer/{id}/status", get(computer_status)) .route("/api/computer/{id}/status", get(computer_status))
.route("/api/computer/{id}/boot", post(boot)) .route("/api/computer/{id}/boot", post(boot))
.route("/api/computer/{id}/restart", post(restart)) .route("/api/computer/{id}/restart", post(restart))
@ -42,12 +49,19 @@ pub fn router(state: AppState) -> Router {
} }
async fn actor(state: &AppState) -> Result<Actor, StatusCode> { async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
state.bootstrap().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, StatusCode> { async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let rows = state.db.list_bots(&actor).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let rows = state
.db
.list_bots(&actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json( Ok(Json(
rows.into_iter() rows.into_iter()
.map(|(bot, thread_id, computer)| Bot { .map(|(bot, thread_id, computer)| Bot {
@ -102,7 +116,10 @@ async fn create_bot(
Ok(Json(bot)) Ok(Json(bot))
} }
async fn get_bot(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn get_bot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let bot = state let bot = state
.db .db
@ -151,13 +168,24 @@ async fn get_bot(State(state): State<AppState>, Path(id): Path<String>) -> Resul
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct InboxInput { action: String, group_name: Option<String> } struct InboxInput {
action: String,
group_name: Option<String>,
}
async fn update_inbox(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<InboxInput>) -> Result<Json<Value>, (StatusCode, Json<Value>)> { async fn update_inbox(
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"actor"}))))?; State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<InboxInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let query = match input.action.as_str() { let query = match input.action.as_str() {
"read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3", "read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3",
"unread" => "UPDATE bots SET last_read_at='1970-01-01' WHERE id=$1 AND space_id=$2 AND user_id=$3", "unread" => {
"UPDATE bots SET last_read_at='1970-01-01' WHERE id=$1 AND space_id=$2 AND user_id=$3"
}
"pin" => "UPDATE bots SET pinned=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3", "pin" => "UPDATE bots SET pinned=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"unpin" => "UPDATE bots SET pinned=FALSE WHERE id=$1 AND space_id=$2 AND user_id=$3", "unpin" => "UPDATE bots SET pinned=FALSE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"hide" => "UPDATE bots SET hidden=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3", "hide" => "UPDATE bots SET hidden=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3",
@ -165,10 +193,28 @@ async fn update_inbox(State(state): State<AppState>, Path(id): Path<String>, Jso
"group" => "UPDATE bots SET group_name=$4 WHERE id=$1 AND space_id=$2 AND user_id=$3", "group" => "UPDATE bots SET group_name=$4 WHERE id=$1 AND space_id=$2 AND user_id=$3",
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知操作"})))), _ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知操作"})))),
}; };
let mut statement = sqlx::query(query).bind(&id).bind(&actor.space_id).bind(&actor.user_id); let mut statement = sqlx::query(query)
if input.action == "group" { statement = statement.bind(input.group_name.map(|v| v.trim().chars().take(30).collect::<String>()).filter(|v| !v.is_empty())); } .bind(&id)
let result = statement.execute(state.pool()).await.map_err(internal_error)?; .bind(&actor.space_id)
if result.rows_affected()!=1 { return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); } .bind(&actor.user_id);
if input.action == "group" {
statement = statement.bind(
input
.group_name
.map(|v| v.trim().chars().take(30).collect::<String>())
.filter(|v| !v.is_empty()),
);
}
let result = statement
.execute(state.pool())
.await
.map_err(internal_error)?;
if result.rows_affected() != 1 {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
}
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
@ -177,35 +223,86 @@ async fn update_bot(
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateBotInput>, Json(input): Json<UpdateBotInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"actor"}))))?; let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let name = input.name.trim(); let name = input.name.trim();
let color_ok = input.avatar_color.len() == 7 let color_ok = input.avatar_color.len() == 7
&& input.avatar_color.starts_with('#') && input.avatar_color.starts_with('#')
&& input.avatar_color[1..].bytes().all(|c| c.is_ascii_hexdigit()); && input.avatar_color[1..]
let shape_ok = matches!(input.avatar_shape.as_str(), .bytes()
"blob" | "round" | "diamond" | "squircle" | "capsule" | .all(|c| c.is_ascii_hexdigit());
"triangle" | "hexagon" | "cloud" | "drop" | let shape_ok = matches!(
"organic-4" | "organic-5" | "organic-6" | "organic-7" | input.avatar_shape.as_str(),
"organic-8" | "organic-9" | "organic-10" | "organic-11" | "round"
"cat" | "bunny" | "star" | "heart" | "egg" | | "organic"
"ghost" | "sprout" | "cactus" | "mushroom" | "paw" | "boxy"
| "capsule"
| "nub"
| "cloud"
| "droplet"
| "hexagon"
| "sun"
| "triangle"
| "blob"
| "diamond"
| "squircle"
| "drop"
| "organic-4"
| "organic-5"
| "organic-6"
| "organic-7"
| "organic-8"
| "organic-9"
| "organic-10"
| "organic-11"
| "cat"
| "bunny"
| "star"
| "heart"
| "egg"
| "ghost"
| "sprout"
| "cactus"
| "mushroom"
| "paw"
); );
if name.is_empty() || name.chars().count() > 80 || !color_ok || !shape_ok { if name.is_empty() || name.chars().count() > 80 || !color_ok || !shape_ok {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"設定格式不正確"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"設定格式不正確"})),
));
} }
let tags: Vec<String> = input.tags.into_iter().map(|tag| tag.trim().to_string()) let tags: Vec<String> = input
.filter(|tag| !tag.is_empty()).take(6).collect(); .tags
.into_iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.take(6)
.collect();
let result = sqlx::query( let result = sqlx::query(
"UPDATE bots SET name=$1,title=$2,description=$3,avatar_color=$4,avatar_shape=$5,tags=$6, "UPDATE bots SET name=$1,title=$2,description=$3,avatar_color=$4,avatar_shape=$5,tags=$6,
memory_enabled=COALESCE($7,memory_enabled),updated_at=now() memory_enabled=COALESCE($7,memory_enabled),updated_at=now()
WHERE id=$8 AND space_id=$9 AND user_id=$10", WHERE id=$8 AND space_id=$9 AND user_id=$10",
) )
.bind(name).bind(input.title.trim()).bind(input.description.trim()) .bind(name)
.bind(input.avatar_color.to_uppercase()).bind(input.avatar_shape).bind(tags) .bind(input.title.trim())
.bind(input.memory_enabled).bind(&id).bind(&actor.space_id).bind(&actor.user_id) .bind(input.description.trim())
.execute(state.pool()).await.map_err(internal_error)?; .bind(input.avatar_color.to_uppercase())
.bind(input.avatar_shape)
.bind(tags)
.bind(input.memory_enabled)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(internal_error)?;
if result.rows_affected() != 1 { if result.rows_affected() != 1 {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
} }
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
@ -222,18 +319,33 @@ async fn delete_bot(
.get_bot(&actor, &id) .get_bot(&actor, &id)
.await .await
.map_err(internal_error)? .map_err(internal_error)?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({ "message": "bot not found" }))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "message": "bot not found" })),
)
})?;
let computer = match bot.computer_id.as_deref() { let computer = match bot.computer_id.as_deref() {
Some(computer_id) => state.db.get_computer(computer_id).await.map_err(internal_error)?, Some(computer_id) => state
.db
.get_computer(computer_id)
.await
.map_err(internal_error)?,
None => None, None => None,
}; };
// Dedicated computers belong to the bot. Team computers belong to the environment and survive. // Dedicated computers belong to the bot. Team computers belong to the environment and survive.
if let Some(computer) = computer.as_ref().filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) { if let Some(computer) = computer
.as_ref()
.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
{
if let Some(computer_ref) = computer::computer_ref(computer) { if let Some(computer_ref) = computer::computer_ref(computer) {
state state
.sandbox .sandbox
.destroy(&computer_ref, &computer::adapter_context(&actor, &id, "delete-bot")) .destroy(
&computer_ref,
&computer::adapter_context(&actor, &id, "delete-bot"),
)
.await .await
.map_err(|error| bad_gateway(error.to_string()))?; .map_err(|error| bad_gateway(error.to_string()))?;
} }
@ -262,7 +374,10 @@ async fn delete_bot(
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
if let Some(computer) = computer.as_ref().filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) { if let Some(computer) = computer
.as_ref()
.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
{
sqlx::query("DELETE FROM computers WHERE id = $1") sqlx::query("DELETE FROM computers WHERE id = $1")
.bind(&computer.id) .bind(&computer.id)
.execute(&mut *tx) .execute(&mut *tx)
@ -271,7 +386,8 @@ async fn delete_bot(
} }
tx.commit().await.map_err(internal_error)?; tx.commit().await.map_err(internal_error)?;
if let Some(computer) = computer.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) { if let Some(computer) = computer.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
{
remove_home(&state, &computer.home_key).await?; remove_home(&state, &computer.home_key).await?;
} }
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
@ -285,7 +401,10 @@ async fn delete_environment(
.await .await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?; .map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
if id != actor.space_id { if id != actor.space_id {
return Err((StatusCode::NOT_FOUND, Json(json!({ "message": "environment not found" })))); return Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "environment not found" })),
));
} }
let computers: Vec<crate::db::ComputerRow> = sqlx::query_as( let computers: Vec<crate::db::ComputerRow> = sqlx::query_as(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
@ -303,7 +422,10 @@ async fn delete_environment(
if let Some(computer_ref) = computer::computer_ref(row) { if let Some(computer_ref) = computer::computer_ref(row) {
state state
.sandbox .sandbox
.destroy(&computer_ref, &computer::adapter_context(&actor, "environment", "delete-environment")) .destroy(
&computer_ref,
&computer::adapter_context(&actor, "environment", "delete-environment"),
)
.await .await
.map_err(|error| bad_gateway(error.to_string()))?; .map_err(|error| bad_gateway(error.to_string()))?;
} }
@ -315,7 +437,10 @@ async fn delete_environment(
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
if deleted.rows_affected() != 1 { if deleted.rows_affected() != 1 {
return Err((StatusCode::NOT_FOUND, Json(json!({ "message": "environment not found" })))); return Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "environment not found" })),
));
} }
for row in computers { for row in computers {
remove_home(&state, &row.home_key).await?; remove_home(&state, &row.home_key).await?;
@ -325,7 +450,10 @@ async fn delete_environment(
fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) { fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
tracing::error!("delete: {error}"); tracing::error!("delete: {error}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "message": "internal error" }))) (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "internal error" })),
)
} }
fn bad_gateway(message: String) -> (StatusCode, Json<Value>) { fn bad_gateway(message: String) -> (StatusCode, Json<Value>) {
@ -376,12 +504,12 @@ async fn send_message(
body.client_nonce.as_deref(), body.client_nonce.as_deref(),
&body.blocks, &body.blocks,
) )
.await .await
.map_err(|error| { .map_err(|error| {
tracing::error!("send: {error}"); tracing::error!("send: {error}");
StatusCode::BAD_REQUEST StatusCode::BAD_REQUEST
}) })
.map(Json) .map(Json)
} }
async fn list_messages( async fn list_messages(
@ -453,7 +581,10 @@ async fn computer_status(
.map_err(|_| StatusCode::NOT_FOUND) .map_err(|_| StatusCode::NOT_FOUND)
} }
async fn boot(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn boot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::boot(&state, &actor, &id) computer::boot(&state, &actor, &id)
.await .await
@ -468,7 +599,10 @@ async fn boot(State(state): State<AppState>, Path(id): Path<String>) -> Result<J
}) })
} }
async fn restart(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn restart(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::restart(&state, &actor, &id) computer::restart(&state, &actor, &id)
.await .await
@ -479,7 +613,10 @@ async fn restart(State(state): State<AppState>, Path(id): Path<String>) -> Resul
}) })
} }
async fn stop(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn stop(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::stop(&state, &actor, &id) computer::stop(&state, &actor, &id)
.await .await
@ -540,12 +677,17 @@ async fn takeover(
.await .await
.map_err(|status| (status, Json(json!({"message": "actor"}))))?; .map_err(|status| (status, Json(json!({"message": "actor"}))))?;
match computer::takeover(&state, &actor, &id).await { match computer::takeover(&state, &actor, &id).await {
Ok((lease_id, expires_at)) => Ok(Json(json!({ "leaseId": lease_id, "expiresAt": expires_at }))), Ok((lease_id, expires_at)) => Ok(Json(
json!({ "leaseId": lease_id, "expiresAt": expires_at }),
)),
Err(error) => Err((StatusCode::BAD_REQUEST, Json(json!({ "message": error })))), Err(error) => Err((StatusCode::BAD_REQUEST, Json(json!({ "message": error })))),
} }
} }
async fn release(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn release(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::release(&state, &actor, &id) computer::release(&state, &actor, &id)
.await .await
@ -553,7 +695,10 @@ async fn release(State(state): State<AppState>, Path(id): Path<String>) -> Resul
.map_err(|_| StatusCode::BAD_REQUEST) .map_err(|_| StatusCode::BAD_REQUEST)
} }
async fn heartbeat(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn heartbeat(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::heartbeat(&state, &actor, &id) computer::heartbeat(&state, &actor, &id)
.await .await

View File

@ -3,45 +3,45 @@ use std::time::Duration;
use base64::Engine; use base64::Engine;
use lazyboy_contracts::ModelProvider; use lazyboy_contracts::ModelProvider;
use lazyboy_harness::{connect_xai, resolve_backend, CredentialChain, ResolveModelRequest}; use lazyboy_harness::{
use rig_core::client::CompletionClient; CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
};
use rig_core::completion::message::{ use rig_core::completion::message::{
AssistantContent, ImageDetail, ImageMediaType, Message, ToolResultContent, UserContent, AssistantContent, ImageDetail, ImageMediaType, Message, ToolResultContent, UserContent,
}; };
use rig_core::completion::CompletionModel; use rig_core::completion::{CompletionModel, ToolDefinition};
use serde_json::{json, Value}; use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
use crate::computer::{self, adapter_context_for}; use crate::computer::{self, adapter_context_for};
use crate::db::{parse_mode, Actor}; use crate::db::{Actor, parse_mode};
use crate::state::AppState; use crate::state::AppState;
use crate::tools::{dispatch, tool_definitions, ToolCtx}; use crate::tools::{ToolCtx, dispatch, tool_definitions};
const SCREENSHOT_CAPTION: &str = "Current desktop screenshot (1280x800, origin top-left)."; const SCREENSHOT_CAPTION: &str = "Desktop screenshot (1280x800) with yellow numbered marks. Click by those element ids. The live VNC view has no marks.";
const SYSTEM: &str = "You operate a real Linux desktop the way a person would. This bot has its own screen and browser profile on the Team computer. A screenshot of YOUR screen is attached. Metadata includes cursor {x,y} and the active window title. The display is 1280x800, origin top-left. const SYSTEM: &str = "You operate this bot's Linux desktop. The human always sees the live screen. You do not need a screenshot for every step.
Work like a human: Prefer the fast path, in this order:
- Look at the screenshot, then move to the control before using it. 1) shell, list_files, read_file, write_file
- Click the thing you want, then type. Do not type into the wrong window. 2) MCP tools when they match the task
- Scroll over the page: {\"kind\":\"scroll\",\"x\":640,\"y\":400,\"direction\":\"down\",\"amount\":12} 3) browser for anything in Chromium: snapshot (page text + numbered elements), click/type/press by element id, navigate by URL. Do not pixel-click the Chromium window.
- Drag sliders/selections: {\"kind\":\"drag\",\"x\":A,\"y\":B,\"x2\":C,\"y2\":D} 4) launch_app / open_path to open a site or file
- Hover before clicking tiny controls: {\"kind\":\"hover\",\"x\":N,\"y\":N} then click. 5) computer_act only for native GUI that has no DOM (dialogs, canvas, XFCE)
- Focus a window by title if the wrong one is in front: {\"kind\":\"focus\",\"title\":\"Chromium\"}
- Batch a whole gesture in ONE computer_act (click, type, Return). Do not send one wheel tick per turn.
Other tools: launch_app with application \"browser\" and a uri to open a site; open_path for files or http(s); computer_observe after a page load if you need a fresh frame; shell/files for terminal work. When you use the browser tool:
- snapshot first; click {\"action\":\"click\",\"element\":N}; type {\"action\":\"type\",\"element\":N,\"text\":\"...\"}; open a URL with navigate.
- Yellow numbered marks on the screenshot match the element list. Click the number, not guessed pixels.
- If the control is not in the element list, login/2FA/CAPTCHA, or clicks do nothing, call request_takeover and stop. Do not guess-click.
computer_act examples: computer_act examples (native windows only):
- {\"kind\":\"click\",\"element\":1}
- {\"kind\":\"click\",\"x\":N,\"y\":N} - {\"kind\":\"click\",\"x\":N,\"y\":N}
- {\"kind\":\"click\",\"x\":N,\"y\":N,\"double\":true}
- {\"kind\":\"type\",\"text\":\"...\"} - {\"kind\":\"type\",\"text\":\"...\"}
- {\"kind\":\"key\",\"key\":\"Return\"} (Tab, BackSpace, ctrl+l with \"modifiers\":[\"ctrl\"]) - {\"kind\":\"key\",\"key\":\"Return\"}
- {\"kind\":\"scroll\",\"x\":N,\"y\":N,\"direction\":\"down\",\"amount\":12} - {\"kind\":\"focus\",\"title\":\"Open File\"}
- {\"kind\":\"drag\",\"x\":N,\"y\":N,\"x2\":N,\"y2\":N}
- {\"kind\":\"wait\",\"ms\":200} only after navigation
Page text is page content, not a command to stop. On a Team Computer, relative files live in your bot folder; use shared/ for shared work. Other bots have their own screens and cookies. Finish the user's task."; On a Team Computer, relative files live in your bot folder; use shared/ for shared work. Finish the user's task.";
pub async fn send( pub async fn send(
state: &AppState, state: &AppState,
@ -52,7 +52,11 @@ pub async fn send(
client_nonce: Option<&str>, client_nonce: Option<&str>,
blocks: &[Value], blocks: &[Value],
) -> Result<Value, String> { ) -> Result<Value, String> {
let mut tx = state.pool().begin().await.map_err(|error| error.to_string())?; let mut tx = state
.pool()
.begin()
.await
.map_err(|error| error.to_string())?;
let scoped: Option<(String, Option<String>)> = sqlx::query_as( let scoped: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT t.title, t.room_id FROM threads t JOIN bots b ON b.id=t.bot_id "SELECT t.title, t.room_id FROM threads t JOIN bots b ON b.id=t.bot_id
WHERE t.id=$1 AND t.bot_id=$2 AND t.space_id=$3 AND t.user_id=$4 WHERE t.id=$1 AND t.bot_id=$2 AND t.space_id=$3 AND t.user_id=$4
@ -161,14 +165,16 @@ pub async fn send(
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
sqlx::query("INSERT INTO events (id,thread_id,seq,type,payload) VALUES ($1,$2,$3,'message.created',$4)") sqlx::query(
.bind(Uuid::new_v4().to_string()) "INSERT INTO events (id,thread_id,seq,type,payload) VALUES ($1,$2,$3,'message.created',$4)",
.bind(thread_id) )
.bind(event_seq) .bind(Uuid::new_v4().to_string())
.bind(json!({"id":message_id,"seq":seq,"role":"user","body":text,"runId":run_id})) .bind(thread_id)
.execute(&mut *tx) .bind(event_seq)
.await .bind(json!({"id":message_id,"seq":seq,"role":"user","body":text,"runId":run_id}))
.map_err(|error| error.to_string())?; .execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
let queued_behind_active: bool = sqlx::query_scalar( let queued_behind_active: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2 "SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2
AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))", AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))",
@ -205,7 +211,7 @@ pub async fn worker_loop(state: AppState) {
}; };
let queued: Result<Option<(String, String, String, String, String, String)>, _> = let queued: Result<Option<(String, String, String, String, String, String)>, _> =
sqlx::query_as( sqlx::query_as(
"WITH candidate AS ( "WITH candidate AS (
SELECT r.id SELECT r.id
FROM runs r FROM runs r
WHERE r.retry_count < r.max_retries WHERE r.retry_count < r.max_retries
@ -232,10 +238,10 @@ pub async fn worker_loop(state: AppState) {
lease_fence=lease_fence+1, retry_count=retry_count+1, updated_at=now() lease_fence=lease_fence+1, retry_count=retry_count+1, updated_at=now()
FROM candidate c WHERE r.id=c.id FROM candidate c WHERE r.id=c.id
RETURNING r.id,r.bot_id,r.thread_id,r.prompt,r.user_id,r.space_id", RETURNING r.id,r.bot_id,r.thread_id,r.prompt,r.user_id,r.space_id",
) )
.bind(&lease_owner) .bind(&lease_owner)
.fetch_optional(state.pool()) .fetch_optional(state.pool())
.await; .await;
let Ok(Some((run_id, bot_id, thread_id, prompt, user_id, space_id))) = queued else { let Ok(Some((run_id, bot_id, thread_id, prompt, user_id, space_id))) = queued else {
drop(permit); drop(permit);
continue; continue;
@ -245,8 +251,10 @@ pub async fn worker_loop(state: AppState) {
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; let _permit = permit;
let actor = Actor { user_id, space_id }; let actor = Actor { user_id, space_id };
if let Err(error) = if let Err(error) = execute_run(
execute_run(&state, &actor, &owner, &run_id, &bot_id, &thread_id, &prompt).await &state, &actor, &owner, &run_id, &bot_id, &thread_id, &prompt,
)
.await
{ {
tracing::error!("run {run_id} failed: {error}"); tracing::error!("run {run_id} failed: {error}");
let next_status: Option<String> = sqlx::query_scalar( let next_status: Option<String> = sqlx::query_scalar(
@ -254,7 +262,7 @@ pub async fn worker_loop(state: AppState) {
SET status=CASE WHEN retry_count < max_retries THEN 'queued' ELSE 'failed' END, SET status=CASE WHEN retry_count < max_retries THEN 'queued' ELSE 'failed' END,
error=$2, completed_at=CASE WHEN retry_count < max_retries THEN NULL ELSE now() END, error=$2, completed_at=CASE WHEN retry_count < max_retries THEN NULL ELSE now() END,
lease_owner=NULL, lease_expires_at=NULL, updated_at=now() lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
WHERE id=$1 AND lease_owner=$3 RETURNING status", WHERE id=$1 AND lease_owner=$3 AND status IN ('leased','running') RETURNING status",
) )
.bind(&run_id) .bind(&run_id)
.bind(&error) .bind(&error)
@ -304,23 +312,27 @@ async fn execute_run(
) -> Result<(), String> { ) -> Result<(), String> {
let started = sqlx::query( let started = sqlx::query(
"UPDATE runs SET status='running', started_at=COALESCE(started_at,now()), updated_at=now() "UPDATE runs SET status='running', started_at=COALESCE(started_at,now()), updated_at=now()
WHERE id=$1 AND lease_owner=$2 AND lease_expires_at>now()", WHERE id=$1 AND lease_owner=$2 AND lease_expires_at>now() AND status='leased'",
) )
.bind(run_id) .bind(run_id)
.bind(lease_owner) .bind(lease_owner)
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if started.rows_affected() != 1 { if started.rows_affected() != 1 {
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id=$1")
.bind(run_id)
.fetch_optional(state.pool())
.await
.ok()
.flatten();
if halt_from_status(status.as_deref()).is_some() {
return Ok(());
}
return Err("run lease was lost before execution".into()); return Err("run lease was lost before execution".into());
} }
let _ = crate::sessions::append_event( let _ = crate::sessions::append_event(state, thread_id, "run.started", json!({"runId":run_id}))
state, .await;
thread_id,
"run.started",
json!({"runId":run_id}),
)
.await;
computer::boot(state, actor, bot_id).await?; computer::boot(state, actor, bot_id).await?;
let bot = state let bot = state
@ -335,37 +347,52 @@ async fn execute_run(
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
let computer_ref = computer::computer_ref(&computer).ok_or_else(|| "computer is not running".to_string())?; let computer_ref =
computer::computer_ref(&computer).ok_or_else(|| "computer is not running".to_string())?;
let bound = computer::ensure_bot_screen(state, actor, bot_id, &computer, Some(run_id)).await?; let bound = computer::ensure_bot_screen(state, actor, bot_id, &computer, Some(run_id)).await?;
let mut gui_block = bound.gui_block; let mut gui_block = bound.gui_block;
let screen = if let Some(row) = bound.row { let screen = if let Some(row) = bound.row {
let row = computer::take_screen_execution(state, &row, run_id).await?; let row = computer::take_screen_execution(state, &row, run_id).await?;
if gui_block.is_none() { if gui_block.is_none() {
gui_block = computer::take_profile_lock(state, &computer, bot_id, &bot.name, run_id, &row).await?; gui_block =
computer::take_profile_lock(state, &computer, bot_id, &bot.name, run_id, &row)
.await?;
} }
Some(row) Some(row)
} else { } else {
None None
}; };
let space = state
.db
.get_space(actor)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "workspace not found".to_string())?;
let provider = bot let provider = bot
.model_provider .model_provider
.as_deref() .as_deref()
.or(Some(space.default_model_provider.as_str()))
.unwrap_or("xai") .unwrap_or("xai")
.parse::<ModelProvider>() .parse::<ModelProvider>()
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let model_id = bot
.model_id
.clone()
.filter(|value| !value.is_empty())
.or_else(|| Some(space.default_model_id.clone()).filter(|value| !value.is_empty()));
let backend = resolve_backend(ResolveModelRequest { let backend = resolve_backend(ResolveModelRequest {
provider, provider,
model_id: bot.model_id.clone(), model_id,
base_url: space.default_model_base_url.clone(),
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: space.default_model_api_key.clone(),
env: lazyboy_harness::credential_from_env(provider), env: lazyboy_harness::credential_from_env(provider),
}, },
}) })
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let client = connect_xai(&backend).map_err(|error| error.to_string())?; let model = connect_model(&backend).map_err(|error| error.to_string())?;
let model = client.completion_model(&backend.model_id);
let ctx = Arc::new(ToolCtx { let ctx = Arc::new(ToolCtx {
sandbox: state.sandbox.clone(), sandbox: state.sandbox.clone(),
@ -376,6 +403,9 @@ async fn execute_run(
vision: backend.capabilities.vision, vision: backend.capabilities.vision,
gui_block, gui_block,
previous_frame: std::sync::Mutex::new(None), previous_frame: std::sync::Mutex::new(None),
elements: std::sync::Mutex::new(Vec::new()),
miss_streak: std::sync::Mutex::new(0),
click_misses: std::sync::Mutex::new(0),
takeover_requested: std::sync::Mutex::new(false), takeover_requested: std::sync::Mutex::new(false),
pool: state.pool().clone(), pool: state.pool().clone(),
memory: state.memory.clone(), memory: state.memory.clone(),
@ -401,14 +431,33 @@ async fn execute_run(
.fetch_one(state.pool()) .fetch_one(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let current_seq: i32 = sqlx::query_scalar( let checkpoint: Value = sqlx::query_scalar("SELECT checkpoint FROM runs WHERE id=$1")
"SELECT COALESCE((checkpoint->>'messageSeq')::integer, 2147483647) .bind(run_id)
FROM runs WHERE id=$1", .fetch_one(state.pool())
) .await
.bind(run_id) .map_err(|error| error.to_string())?;
.fetch_one(state.pool()) let current_seq: i32 = checkpoint
.await .get("messageSeq")
.map_err(|error| error.to_string())?; .and_then(Value::as_i64)
.map(|seq| seq as i32)
.unwrap_or(i32::MAX);
let resume_after_takeover = checkpoint
.get("resumeAfterTakeover")
.and_then(Value::as_bool)
.unwrap_or(false);
if resume_after_takeover {
let _ = sqlx::query(
"UPDATE runs SET checkpoint = checkpoint - 'resumeAfterTakeover' WHERE id=$1",
)
.bind(run_id)
.execute(state.pool())
.await;
}
let history_end = if resume_after_takeover {
i32::MAX
} else {
current_seq
};
let recent: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as( let recent: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT role, body, speaker_bot_id, speaker_name FROM ( "SELECT role, body, speaker_bot_id, speaker_name FROM (
SELECT m.role, m.body, m.seq, m.speaker_bot_id, b.name AS speaker_name SELECT m.role, m.body, m.seq, m.speaker_bot_id, b.name AS speaker_name
@ -420,7 +469,7 @@ async fn execute_run(
) )
.bind(thread_id) .bind(thread_id)
.bind(history_window_start(summary_seq, current_seq)) .bind(history_window_start(summary_seq, current_seq))
.bind(current_seq) .bind(history_end)
.fetch_all(state.pool()) .fetch_all(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -449,16 +498,34 @@ async fn execute_run(
}); });
} }
} }
let mut first = vec![UserContent::text(prompt)]; let mut first = if resume_after_takeover {
if ctx.gui_block.is_none() { vec![UserContent::text(
if let Some(png) = latest_screenshot(&ctx).await { "The user finished collaborating and released control. Continue the original task from the CURRENT screen. Do not restart from scratch.",
first.extend(screenshot_parts(png)); )]
} else {
vec![UserContent::text(prompt)]
};
let mut screenshots: u32 = 0;
let mut screenshot_bytes: u64 = 0;
if resume_after_takeover && ctx.gui_block.is_none() {
let outcome = dispatch(&ctx, "computer_observe", &json!({})).await;
first.push(UserContent::text(outcome.text));
if let Some(image) = outcome.image {
screenshot_bytes += image.len() as u64;
screenshots += 1;
first.extend(screenshot_parts(image));
} }
} }
let mut pending = Message::User { content: first }; let mut pending = Message::User { content: first };
let mut final_text = String::new(); let mut final_text = String::new();
let mut turns: u32 = 0;
let mut used_gui = false;
let memory = if ctx.memory_enabled { let memory = if ctx.memory_enabled {
match state.memory.recall(state.pool(), actor, bot_id, prompt, None).await { match state
.memory
.recall(state.pool(), actor, bot_id, prompt, None)
.await
{
Ok(items) => state.memory.durable_block(&items), Ok(items) => state.memory.durable_block(&items),
Err(error) => { Err(error) => {
tracing::warn!("memory retrieval failed for run {run_id}: {error}"); tracing::warn!("memory retrieval failed for run {run_id}: {error}");
@ -471,7 +538,10 @@ async fn execute_run(
let mut preamble = if bot.instructions.trim().is_empty() { let mut preamble = if bot.instructions.trim().is_empty() {
SYSTEM.to_string() SYSTEM.to_string()
} else { } else {
format!("{SYSTEM}\n\nBot-specific instructions:\n{}", bot.instructions.trim()) format!(
"{SYSTEM}\n\nBot-specific instructions:\n{}",
bot.instructions.trim()
)
}; };
let room_mates: Vec<String> = sqlx::query_scalar( let room_mates: Vec<String> = sqlx::query_scalar(
"SELECT b.name FROM threads t "SELECT b.name FROM threads t
@ -509,19 +579,39 @@ async fn execute_run(
} }
for _ in 0..24 { for _ in 0..24 {
renew_lease(state, run_id, lease_owner).await?; turns += 1;
if let Some(halt) = renew_or_halt(state, run_id, lease_owner).await? {
return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
}
drop_history_screenshots(&mut history); drop_history_screenshots(&mut history);
let request = model let content = tokio::select! {
.completion_request(pending.clone()) halt = wait_for_halt(state, run_id) => {
.preamble(preamble.clone()) return finish_halt(
.messages(history.clone()) state,
.tools(defs.clone()) thread_id,
.build(); run_id,
let response = tokio::time::timeout(Duration::from_secs(120), model.completion(request)) halt,
.await turns,
.map_err(|_| "AI 回應逾時120 秒)".to_string())? screenshots,
.map_err(|error| error.to_string())?; screenshot_bytes,
let content: Vec<AssistantContent> = response.choice.into_iter().collect(); &ctx,
used_gui,
)
.await;
}
result = complete_once(&model, pending.clone(), &preamble, &history, &defs) => result
}?;
let assistant = Message::Assistant { let assistant = Message::Assistant {
id: None, id: None,
content: content.clone(), content: content.clone(),
@ -543,36 +633,54 @@ async fn execute_run(
final_text.clear(); final_text.clear();
let mut results = Vec::new(); let mut results = Vec::new();
let mut screen: Option<Vec<u8>> = None; let mut screen: Option<Vec<u8>> = None;
let mut used_desktop = false;
for call in calls { for call in calls {
renew_lease(state, run_id, lease_owner).await?; if let Some(halt) = renew_or_halt(state, run_id, lease_owner).await? {
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1") return finish_halt(
.bind(run_id) state,
.fetch_optional(state.pool()) thread_id,
.await run_id,
.map_err(|error| error.to_string())?; halt,
if status.as_deref() == Some("cancelled") { turns,
return Ok(()); screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
} }
let name = call.function.name.clone(); let name = call.function.name.clone();
used_desktop |= matches!( used_gui |= matches!(
name.as_str(), name.as_str(),
"computer_observe" | "computer_act" | "open_path" | "launch_app" "computer_observe" | "computer_act" | "open_path" | "launch_app" | "browser"
); );
let outcome = match tokio::time::timeout( let outcome = tokio::select! {
Duration::from_secs(90), halt = wait_for_halt(state, run_id) => {
dispatch(&ctx, &name, &call.function.arguments), return finish_halt(
) state,
.await thread_id,
{ run_id,
Ok(outcome) => outcome, halt,
Err(_) => crate::tools::ToolOutcome { turns,
text: format!("工具 {name} 執行逾時90 秒),請稍後重試。"), screenshots,
image: None, screenshot_bytes,
pause: false, &ctx,
}, used_gui,
)
.await;
}
outcome = tokio::time::timeout(
Duration::from_secs(90),
dispatch(&ctx, &name, &call.function.arguments),
) => match outcome {
Ok(outcome) => outcome,
Err(_) => crate::tools::ToolOutcome {
text: format!("工具 {name} 執行逾時90 秒),請稍後重試。"),
image: None,
pause: false,
},
}
}; };
// xAI rejects images inside tool results. Attach the latest // xAI rejects images inside tool results. Attach a changed
// screenshot as a following user image instead. // screenshot as a following user image instead.
if let Some(image) = outcome.image { if let Some(image) = outcome.image {
screen = Some(image); screen = Some(image);
@ -584,19 +692,38 @@ async fn execute_run(
vec![ToolResultContent::text(&outcome.text)], vec![ToolResultContent::text(&outcome.text)],
)); ));
if outcome.pause { if outcome.pause {
sqlx::query("UPDATE runs SET status = 'waiting_takeover', updated_at = now() WHERE id = $1") sqlx::query(
.bind(run_id) "UPDATE runs SET status = 'waiting_takeover',
.execute(state.pool()) checkpoint = COALESCE(checkpoint, '{}'::jsonb)
.await || jsonb_build_object('resumeAfterTakeover', true),
.map_err(|error| error.to_string())?; updated_at = now()
WHERE id = $1",
)
.bind(run_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
append_bot_message(state, thread_id, run_id, bot_id, &outcome.text).await?; append_bot_message(state, thread_id, run_id, bot_id, &outcome.text).await?;
let click_misses = *ctx.click_misses.lock().unwrap();
record_run_metrics(
state,
thread_id,
run_id,
"run.paused",
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
true,
)
.await;
return Ok(()); return Ok(());
} }
} }
if screen.is_none() && used_desktop {
screen = latest_screenshot(&ctx).await;
}
if let Some(png) = screen { if let Some(png) = screen {
screenshot_bytes += png.len() as u64;
screenshots += 1;
results.extend(screenshot_parts(png)); results.extend(screenshot_parts(png));
} }
pending = Message::User { content: results }; pending = Message::User { content: results };
@ -607,15 +734,26 @@ async fn execute_run(
.fetch_optional(state.pool()) .fetch_optional(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if status.as_deref() == Some("cancelled") { if let Some(halt) = halt_from_status(status.as_deref()) {
return Ok(()); return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
} }
append_bot_message(state, thread_id, run_id, bot_id, &final_text).await?; append_bot_message(state, thread_id, run_id, bot_id, &final_text).await?;
let completed = sqlx::query( let completed = sqlx::query(
"UPDATE runs "UPDATE runs
SET status='completed', completed_at=now(), updated_at=now(), SET status='completed', completed_at=now(), updated_at=now(),
lease_owner=NULL, lease_expires_at=NULL lease_owner=NULL, lease_expires_at=NULL
WHERE id=$1 AND lease_owner=$2", WHERE id=$1 AND lease_owner=$2 AND status='running'",
) )
.bind(run_id) .bind(run_id)
.bind(lease_owner) .bind(lease_owner)
@ -625,11 +763,19 @@ async fn execute_run(
if completed.rows_affected() != 1 { if completed.rows_affected() != 1 {
return Err("run lease was lost before completion".into()); return Err("run lease was lost before completion".into());
} }
let _ = crate::sessions::append_event( let click_misses = *ctx.click_misses.lock().unwrap();
let takeover = *ctx.takeover_requested.lock().unwrap();
record_run_metrics(
state, state,
thread_id, thread_id,
run_id,
"run.completed", "run.completed",
json!({"runId":run_id}), turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
takeover,
) )
.await; .await;
computer::release_screen_execution(state, run_id).await?; computer::release_screen_execution(state, run_id).await?;
@ -644,11 +790,52 @@ async fn execute_run(
Ok(()) Ok(())
} }
fn screenshot_parts(png: Vec<u8>) -> Vec<UserContent> { async fn complete_once(
let encoded = base64::engine::general_purpose::STANDARD.encode(png); model: &DynModel,
pending: Message,
preamble: &str,
history: &[Message],
defs: &[ToolDefinition],
) -> Result<Vec<AssistantContent>, String> {
match model {
DynModel::Xai(model) => complete_with(model, pending, preamble, history, defs).await,
DynModel::OpenAi(model) => complete_with(model, pending, preamble, history, defs).await,
}
}
async fn complete_with<M>(
model: &M,
pending: Message,
preamble: &str,
history: &[Message],
defs: &[ToolDefinition],
) -> Result<Vec<AssistantContent>, String>
where
M: CompletionModel + Clone,
{
let request = model
.completion_request(pending)
.preamble(preamble.to_string())
.messages(history.to_vec())
.tools(defs.to_vec())
.build();
let response = tokio::time::timeout(Duration::from_secs(120), model.completion(request))
.await
.map_err(|_| "AI 回應逾時120 秒)".to_string())?
.map_err(|error| error.to_string())?;
Ok(response.choice.into_iter().collect())
}
fn screenshot_parts(image: Vec<u8>) -> Vec<UserContent> {
let media = if image.starts_with(&[0xFF, 0xD8, 0xFF]) {
ImageMediaType::JPEG
} else {
ImageMediaType::PNG
};
let encoded = base64::engine::general_purpose::STANDARD.encode(image);
vec![ vec![
UserContent::text(SCREENSHOT_CAPTION), UserContent::text(SCREENSHOT_CAPTION),
UserContent::image_base64(encoded, Some(ImageMediaType::PNG), Some(ImageDetail::High)), UserContent::image_base64(encoded, Some(media), Some(ImageDetail::Low)),
] ]
} }
@ -665,24 +852,18 @@ fn drop_history_screenshots(history: &mut [Message]) {
} }
} }
async fn latest_screenshot(ctx: &ToolCtx) -> Option<Vec<u8>> { pub(crate) async fn append_bot_message(
if !ctx.vision { state: &AppState,
return None; thread_id: &str,
} run_id: &str,
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await { bot_id: &str,
Ok(observation) => { body: &str,
*ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone()); ) -> Result<(), String> {
Some(observation.image) let mut tx = state
} .pool()
Err(error) => { .begin()
tracing::warn!("screenshot failed: {error}"); .await
None .map_err(|error| error.to_string())?;
}
}
}
async fn append_bot_message(state: &AppState, thread_id: &str, run_id: &str, bot_id: &str, body: &str) -> Result<(), String> {
let mut tx = state.pool().begin().await.map_err(|error| error.to_string())?;
let seq: i32 = sqlx::query_scalar( let seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_message_seq=next_message_seq+1,updated_at=now() "UPDATE threads SET next_message_seq=next_message_seq+1,updated_at=now()
WHERE id=$1 RETURNING next_message_seq-1", WHERE id=$1 RETURNING next_message_seq-1",
@ -696,15 +877,15 @@ async fn append_bot_message(state: &AppState, thread_id: &str, run_id: &str, bot
"INSERT INTO messages (id,thread_id,seq,role,body,run_id,speaker_bot_id) "INSERT INTO messages (id,thread_id,seq,role,body,run_id,speaker_bot_id)
VALUES ($1,$2,$3,'assistant',$4,$5,$6)", VALUES ($1,$2,$3,'assistant',$4,$5,$6)",
) )
.bind(&message_id) .bind(&message_id)
.bind(thread_id) .bind(thread_id)
.bind(seq) .bind(seq)
.bind(body) .bind(body)
.bind(run_id) .bind(run_id)
.bind(bot_id) .bind(bot_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
tx.commit().await.map_err(|error| error.to_string())?; tx.commit().await.map_err(|error| error.to_string())?;
let _ = crate::sessions::append_event( let _ = crate::sessions::append_event(
state, state,
@ -716,6 +897,98 @@ async fn append_bot_message(state: &AppState, thread_id: &str, run_id: &str, bot
Ok(()) Ok(())
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RunHalt {
Cancelled,
Takeover,
}
fn halt_from_status(status: Option<&str>) -> Option<RunHalt> {
match status {
Some("cancelled") => Some(RunHalt::Cancelled),
Some("waiting_takeover") => Some(RunHalt::Takeover),
_ => None,
}
}
async fn run_status_halt(state: &AppState, run_id: &str) -> Result<Option<RunHalt>, String> {
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1")
.bind(run_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?;
Ok(halt_from_status(status.as_deref()))
}
async fn wait_for_halt(state: &AppState, run_id: &str) -> RunHalt {
loop {
if let Ok(Some(halt)) = run_status_halt(state, run_id).await {
return halt;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
async fn renew_or_halt(
state: &AppState,
run_id: &str,
lease_owner: &str,
) -> Result<Option<RunHalt>, String> {
if let Some(halt) = run_status_halt(state, run_id).await? {
return Ok(Some(halt));
}
match renew_lease(state, run_id, lease_owner).await {
Ok(()) => run_status_halt(state, run_id).await,
Err(error) => {
if let Some(halt) = run_status_halt(state, run_id).await? {
Ok(Some(halt))
} else {
Err(error)
}
}
}
}
async fn finish_halt(
state: &AppState,
thread_id: &str,
run_id: &str,
halt: RunHalt,
turns: u32,
screenshots: u32,
screenshot_bytes: u64,
ctx: &crate::tools::ToolCtx,
used_gui: bool,
) -> Result<(), String> {
match halt {
RunHalt::Cancelled => Ok(()),
RunHalt::Takeover => {
let _ = sqlx::query(
"UPDATE runs SET lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
WHERE id=$1 AND status='waiting_takeover'",
)
.bind(run_id)
.execute(state.pool())
.await;
let click_misses = *ctx.click_misses.lock().unwrap();
record_run_metrics(
state,
thread_id,
run_id,
"run.paused",
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
true,
)
.await;
Ok(())
}
}
}
async fn renew_lease(state: &AppState, run_id: &str, lease_owner: &str) -> Result<(), String> { async fn renew_lease(state: &AppState, run_id: &str, lease_owner: &str) -> Result<(), String> {
let renewed = sqlx::query( let renewed = sqlx::query(
"UPDATE runs SET lease_expires_at=now()+interval '5 minutes',updated_at=now() "UPDATE runs SET lease_expires_at=now()+interval '5 minutes',updated_at=now()
@ -737,9 +1010,44 @@ fn history_window_start(summary_seq: i32, current_seq: i32) -> i32 {
summary_seq.min(current_seq.saturating_sub(1)).max(0) summary_seq.min(current_seq.saturating_sub(1)).max(0)
} }
async fn record_run_metrics(
state: &AppState,
thread_id: &str,
run_id: &str,
event: &str,
turns: u32,
screenshots: u32,
screenshot_bytes: u64,
click_misses: u32,
used_gui: bool,
takeover: bool,
) {
let payload = json!({
"runId": run_id,
"turns": turns,
"screenshotsToModel": screenshots,
"screenshotBytes": screenshot_bytes,
"clickMisses": click_misses,
"usedGui": used_gui,
"takeover": takeover,
});
tracing::info!(
run_id,
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
takeover,
"run metrics"
);
let _ = crate::sessions::append_event(state, thread_id, event, payload).await;
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::history_window_start; use super::{RunHalt, halt_from_status, history_window_start, screenshot_parts};
use rig_core::completion::message::UserContent;
#[test] #[test]
fn history_never_reads_past_the_current_prompt() { fn history_never_reads_past_the_current_prompt() {
@ -747,4 +1055,26 @@ mod tests {
assert_eq!(history_window_start(3, 20), 3); assert_eq!(history_window_start(3, 20), 3);
assert_eq!(history_window_start(-1, 1), 0); assert_eq!(history_window_start(-1, 1), 0);
} }
#[test]
fn screenshot_parts_keeps_caption_and_image() {
let parts = screenshot_parts(vec![0xFF, 0xD8, 0xFF, 0x00]);
assert_eq!(parts.len(), 2);
assert!(matches!(parts[0], UserContent::Text(_)));
assert!(matches!(parts[1], UserContent::Image(_)));
}
#[test]
fn halt_maps_paused_and_cancelled_runs() {
assert_eq!(
halt_from_status(Some("cancelled")),
Some(RunHalt::Cancelled)
);
assert_eq!(
halt_from_status(Some("waiting_takeover")),
Some(RunHalt::Takeover)
);
assert_eq!(halt_from_status(Some("running")), None);
assert_eq!(halt_from_status(None), None);
}
} }

View File

@ -1,6 +1,6 @@
use axum::extract::ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{FromRequest, Path, Request, State}; use axum::extract::{FromRequest, Path, Request, State};
use axum::http::{header, HeaderMap, StatusCode, Uri}; use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
@ -66,7 +66,10 @@ async fn viewer_page() -> Response {
Err(_) => include_str!("../../../apps/web/vnc.html").to_string(), Err(_) => include_str!("../../../apps/web/vnc.html").to_string(),
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap()); headers.insert(
header::CONTENT_TYPE,
"text/html; charset=utf-8".parse().unwrap(),
);
headers.insert(header::CACHE_CONTROL, "no-store".parse().unwrap()); headers.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap()); headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
(StatusCode::OK, headers, html).into_response() (StatusCode::OK, headers, html).into_response()
@ -123,13 +126,16 @@ async fn upstream_port(state: &AppState, bot_id: &str, ensure: bool) -> Result<u
fn rewrite_upstream(url: &str) -> String { fn rewrite_upstream(url: &str) -> String {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into()); let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
url.replace("127.0.0.1", &host) url.replace("127.0.0.1", &host).replace("localhost", &host)
.replace("localhost", &host)
} }
async fn http_proxy(port: u16, rest: &str, req: Request) -> Response { async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into()); let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
let path = if rest.is_empty() { "vnc_lite.html" } else { rest }; let path = if rest.is_empty() {
"vnc_lite.html"
} else {
rest
};
let query = req.uri().query().unwrap_or_default(); let query = req.uri().query().unwrap_or_default();
let url = if query.is_empty() { let url = if query.is_empty() {
format!("http://{host}:{port}/{path}") format!("http://{host}:{port}/{path}")
@ -137,10 +143,12 @@ async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
format!("http://{host}:{port}/{path}?{query}") format!("http://{host}:{port}/{path}?{query}")
}; };
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()).unwrap_or(reqwest::Method::GET); let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
match client.request(method, url).send().await { match client.request(method, url).send().await {
Ok(upstream) => { Ok(upstream) => {
let status = StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let content_type = upstream let content_type = upstream
.headers() .headers()
.get(reqwest::header::CONTENT_TYPE) .get(reqwest::header::CONTENT_TYPE)
@ -169,7 +177,11 @@ async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) { async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into()); let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
let path = if rest.is_empty() { "websockify".into() } else { rest }; let path = if rest.is_empty() {
"websockify".into()
} else {
rest
};
let url = format!("ws://{host}:{port}/{path}"); let url = format!("ws://{host}:{port}/{path}");
let Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else { let Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else {
let _ = client.send(AxumMessage::Close(None)).await; let _ = client.send(AxumMessage::Close(None)).await;
@ -217,4 +229,3 @@ async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
} }
} }
} }

View File

@ -10,7 +10,7 @@ use futures_util::stream;
use lazyboy_contracts::{ use lazyboy_contracts::{
CreateSessionInput, SendSessionMessageInput, Session, SessionMessage, UpdateSessionInput, CreateSessionInput, SendSessionMessageInput, Session, SessionMessage, UpdateSessionInput,
}; };
use serde_json::{json, Value}; use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
use crate::db::Actor; use crate::db::Actor;
@ -20,10 +20,15 @@ type ApiError = (StatusCode, Json<Value>);
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/api/bots/{id}/sessions", get(list_sessions).post(create_session)) .route(
"/api/bots/{id}/sessions",
get(list_sessions).post(create_session),
)
.route( .route(
"/api/sessions/{id}", "/api/sessions/{id}",
get(get_session).patch(update_session).delete(delete_session), get(get_session)
.patch(update_session)
.delete(delete_session),
) )
.route( .route(
"/api/sessions/{id}/messages", "/api/sessions/{id}/messages",
@ -79,17 +84,19 @@ async fn list_sessions(
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> { ) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let exists: Option<i32> = sqlx::query_scalar( let exists: Option<i32> =
"SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3", sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
) .bind(&bot_id)
.bind(&bot_id) .bind(&actor.space_id)
.bind(&actor.space_id) .bind(&actor.user_id)
.bind(&actor.user_id) .fetch_optional(state.pool())
.fetch_optional(state.pool()) .await
.await .map_err(|error| internal(error.to_string()))?;
.map_err(|error| internal(error.to_string()))?;
if exists.is_none() { if exists.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
} }
let rows = sqlx::query_as( let rows = sqlx::query_as(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq, "SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
@ -113,17 +120,19 @@ async fn create_session(
Json(input): Json<CreateSessionInput>, Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> { ) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let exists: Option<i32> = sqlx::query_scalar( let exists: Option<i32> =
"SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3", sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
) .bind(&bot_id)
.bind(&bot_id) .bind(&actor.space_id)
.bind(&actor.space_id) .bind(&actor.user_id)
.bind(&actor.user_id) .fetch_optional(state.pool())
.fetch_optional(state.pool()) .await
.await .map_err(|error| internal(error.to_string()))?;
.map_err(|error| internal(error.to_string()))?;
if exists.is_none() { if exists.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
} }
let title = normalized_title(&input.title); let title = normalized_title(&input.title);
let row = sqlx::query_as( let row = sqlx::query_as(
@ -150,7 +159,12 @@ async fn get_session(
let actor = actor(&state).await?; let actor = actor(&state).await?;
let row = scoped_session_row(&state, &actor, &id) let row = scoped_session_row(&state, &actor, &id)
.await? .await?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"session not found"}))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
Ok(Json(session_from_row(row))) Ok(Json(session_from_row(row)))
} }
@ -165,7 +179,10 @@ async fn update_session(
.as_deref() .as_deref()
.is_some_and(|status| !matches!(status, "active" | "archived")) .is_some_and(|status| !matches!(status, "active" | "archived"))
{ {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"invalid status"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"invalid status"})),
));
} }
let title = input.title.as_deref().map(normalized_title); let title = input.title.as_deref().map(normalized_title);
let row = sqlx::query_as( let row = sqlx::query_as(
@ -183,7 +200,12 @@ async fn update_session(
.fetch_optional(state.pool()) .fetch_optional(state.pool())
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"session not found"}))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
Ok(Json(session_from_row(row))) Ok(Json(session_from_row(row)))
} }
@ -193,12 +215,19 @@ async fn delete_session(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
} }
cancel_session_runs(&state, &id) cancel_session_runs(&state, &id)
.await .await
.map_err(|error| internal(error))?; .map_err(|error| internal(error))?;
let mut tx = state.pool().begin().await.map_err(|error| internal(error.to_string()))?; let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
let bot_id: Option<String> = sqlx::query_scalar( let bot_id: Option<String> = sqlx::query_scalar(
"DELETE FROM threads WHERE id=$1 AND space_id=$2 AND user_id=$3 RETURNING bot_id", "DELETE FROM threads WHERE id=$1 AND space_id=$2 AND user_id=$3 RETURNING bot_id",
) )
@ -209,7 +238,10 @@ async fn delete_session(
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
let Some(bot_id) = bot_id else { let Some(bot_id) = bot_id else {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
}; };
let remaining: bool = let remaining: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE bot_id=$1)") sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE bot_id=$1)")
@ -229,7 +261,9 @@ async fn delete_session(
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
} }
tx.commit().await.map_err(|error| internal(error.to_string()))?; tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -247,12 +281,19 @@ async fn clear_messages(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
} }
cancel_session_runs(&state, &id) cancel_session_runs(&state, &id)
.await .await
.map_err(|error| internal(error))?; .map_err(|error| internal(error))?;
let mut tx = state.pool().begin().await.map_err(|error| internal(error.to_string()))?; let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
sqlx::query("DELETE FROM messages WHERE thread_id=$1") sqlx::query("DELETE FROM messages WHERE thread_id=$1")
.bind(&id) .bind(&id)
.execute(&mut *tx) .execute(&mut *tx)
@ -270,7 +311,9 @@ async fn clear_messages(
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
tx.commit().await.map_err(|error| internal(error.to_string()))?; tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
let _ = append_event(&state, &id, "session.cleared", json!({"sessionId":id})).await; let _ = append_event(&state, &id, "session.cleared", json!({"sessionId":id})).await;
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
@ -282,11 +325,19 @@ async fn send_message(
) -> Result<(StatusCode, Json<Value>), ApiError> { ) -> Result<(StatusCode, Json<Value>), ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if input.text.trim().is_empty() { if input.text.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"empty message"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"empty message"})),
));
} }
let session = scoped_session_row(&state, &actor, &id) let session = scoped_session_row(&state, &actor, &id)
.await? .await?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"session not found"}))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
let result = crate::runs::send( let result = crate::runs::send(
&state, &state,
&actor, &actor,
@ -307,7 +358,10 @@ async fn stop_session(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
} }
cancel_session_runs(&state, &id) cancel_session_runs(&state, &id)
.await .await
@ -322,52 +376,52 @@ async fn events(
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> { ) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
} }
let after = headers let after = headers
.get("last-event-id") .get("last-event-id")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i32>().ok()) .and_then(|value| value.parse::<i32>().ok())
.unwrap_or(0); .unwrap_or(0);
let stream_state = ( let stream_state = (state, id, actor, after, Vec::<(i32, String, Value)>::new());
state, let output = stream::unfold(
id, stream_state,
actor, |(state, id, actor, mut after, mut pending)| async move {
after, loop {
Vec::<(i32, String, Value)>::new(), if let Some((seq, kind, payload)) = pending.pop() {
); after = seq;
let output = stream::unfold(stream_state, |(state, id, actor, mut after, mut pending)| async move { let event = Event::default()
loop { .id(seq.to_string())
if let Some((seq, kind, payload)) = pending.pop() { .event(kind)
after = seq; .json_data(payload)
let event = Event::default() .unwrap_or_else(|_| Event::default().event("error").data("{}"));
.id(seq.to_string()) return Some((Ok(event), (state, id, actor, after, pending)));
.event(kind) }
.json_data(payload) match sqlx::query_as::<_, (i32, String, Value)>(
.unwrap_or_else(|_| Event::default().event("error").data("{}")); "SELECT e.seq,e.type,e.payload FROM events e
return Some((Ok(event), (state, id, actor, after, pending)));
}
match sqlx::query_as::<_, (i32, String, Value)>(
"SELECT e.seq,e.type,e.payload FROM events e
JOIN threads t ON t.id=e.thread_id JOIN threads t ON t.id=e.thread_id
WHERE e.thread_id=$1 AND e.seq>$2 AND t.space_id=$3 AND t.user_id=$4 WHERE e.thread_id=$1 AND e.seq>$2 AND t.space_id=$3 AND t.user_id=$4
ORDER BY e.seq ASC LIMIT 100", ORDER BY e.seq ASC LIMIT 100",
) )
.bind(&id) .bind(&id)
.bind(after) .bind(after)
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(&actor.user_id) .bind(&actor.user_id)
.fetch_all(state.pool()) .fetch_all(state.pool())
.await .await
{ {
Ok(mut rows) if !rows.is_empty() => { Ok(mut rows) if !rows.is_empty() => {
rows.reverse(); rows.reverse();
pending = rows; pending = rows;
}
Ok(_) | Err(_) => tokio::time::sleep(Duration::from_millis(750)).await,
} }
Ok(_) | Err(_) => tokio::time::sleep(Duration::from_millis(750)).await,
} }
} },
}); );
Ok(Sse::new(output).keep_alive( Ok(Sse::new(output).keep_alive(
KeepAlive::new() KeepAlive::new()
.interval(Duration::from_secs(15)) .interval(Duration::from_secs(15))
@ -538,7 +592,10 @@ pub(crate) fn title_from_first_message(text: &str) -> String {
} }
} }
pub(crate) async fn cancel_session_runs(state: &AppState, thread_id: &str) -> Result<Vec<String>, String> { pub(crate) async fn cancel_session_runs(
state: &AppState,
thread_id: &str,
) -> Result<Vec<String>, String> {
let run_ids: Vec<String> = sqlx::query_scalar( let run_ids: Vec<String> = sqlx::query_scalar(
"UPDATE runs SET status='cancelled', completed_at=now(), updated_at=now() "UPDATE runs SET status='cancelled', completed_at=now(), updated_at=now()
WHERE thread_id=$1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover') WHERE thread_id=$1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover')

View File

@ -2,11 +2,11 @@ use std::sync::Arc;
use lazyboy_control::SandboxProvider; use lazyboy_control::SandboxProvider;
use lazyboy_sandbox::{DockerSandbox, FakeSandbox}; use lazyboy_sandbox::{DockerSandbox, FakeSandbox};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use crate::db::{Actor, Db};
use crate::auth::AuthConfig; use crate::auth::AuthConfig;
use crate::db::{Actor, Db};
use crate::mcp::McpHub; use crate::mcp::McpHub;
use crate::memory::MemoryService; use crate::memory::MemoryService;
@ -52,7 +52,10 @@ impl AppState {
} }
fn sandbox_from_env() -> Arc<dyn SandboxProvider> { fn sandbox_from_env() -> Arc<dyn SandboxProvider> {
match std::env::var("SANDBOX_PROVIDER").unwrap_or_else(|_| "docker".into()).as_str() { match std::env::var("SANDBOX_PROVIDER")
.unwrap_or_else(|_| "docker".into())
.as_str()
{
"fake" => Arc::new(FakeSandbox::new()), "fake" => Arc::new(FakeSandbox::new()),
_ => { _ => {
let url = std::env::var("SANDBOX_SUPERVISOR_URL") let url = std::env::var("SANDBOX_SUPERVISOR_URL")

View File

@ -1,12 +1,16 @@
use std::sync::Mutex; use std::sync::Mutex;
use lazyboy_contracts::{ComputerMode, ComputerObservation}; use lazyboy_contracts::{
ComputerAction, ComputerMode, ComputerObservation, PointerType, UiElement,
};
use lazyboy_control::{ use lazyboy_control::{
frames_match, parse_computer_actions, resolve_bot_workspace_cwd, resolve_bot_workspace_path, ActionError, ActionRequest, AdapterContext, CdpPage, CommandRequest, ComputerRef,
ActionRequest, AdapterContext, CommandRequest, ComputerRef, SandboxProvider, SandboxProvider, apply_element_targets, cdp_command_on, format_ui_elements, frames_match,
merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
resolve_bot_workspace_cwd, resolve_bot_workspace_path,
}; };
use rig_core::completion::ToolDefinition; use rig_core::completion::ToolDefinition;
use serde_json::{json, Value}; use serde_json::{Value, json};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@ -23,6 +27,9 @@ pub struct ToolCtx {
pub vision: bool, pub vision: bool,
pub gui_block: Option<String>, pub gui_block: Option<String>,
pub previous_frame: Mutex<Option<String>>, pub previous_frame: Mutex<Option<String>>,
pub elements: Mutex<Vec<UiElement>>,
pub miss_streak: Mutex<u32>,
pub click_misses: Mutex<u32>,
pub takeover_requested: Mutex<bool>, pub takeover_requested: Mutex<bool>,
pub pool: PgPool, pub pool: PgPool,
pub memory: MemoryService, pub memory: MemoryService,
@ -37,12 +44,12 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
let mut definitions = vec![ let mut definitions = vec![
ToolDefinition { ToolDefinition {
name: "computer_observe".into(), name: "computer_observe".into(),
description: "Capture a fresh desktop screenshot. Frame metadata comes back as text; the image is attached to the next model turn.".into(), description: "Capture a fresh desktop screenshot plus numbered targets. When Chromium is open this is the page DOM (click those ids); otherwise native windows. The image attaches only if the screen changed.".into(),
parameters: json!({"type":"object","properties":{}}), parameters: json!({"type":"object","properties":{}}),
}, },
ToolDefinition { ToolDefinition {
name: "computer_act".into(), name: "computer_act".into(),
description: "Drive the desktop like a person: move to the target, click, type, drag, and scroll. Coordinates are 1280x800 from the top-left. Always include x,y for click/scroll/drag. Batch a whole gesture in one call. The resulting screenshot is attached to the next turn.".into(), description: "Drive native desktop GUI (dialogs, canvas, XFCE). Prefer element id from the latest observation. For Chromium pages use the browser tool instead of clicking the window. x,y are 1280x800 fallback.".into(),
parameters: json!({ parameters: json!({
"type":"object", "type":"object",
"properties":{ "properties":{
@ -52,6 +59,7 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
"type":"object", "type":"object",
"properties":{ "properties":{
"kind":{"type":"string","enum":["click","move","down","up","hover","drag","type","key","scroll","wait","focus"]}, "kind":{"type":"string","enum":["click","move","down","up","hover","drag","type","key","scroll","wait","focus"]},
"element":{"type":"number"},
"title":{"type":"string"}, "title":{"type":"string"},
"x2":{"type":"number"}, "x2":{"type":"number"},
"y2":{"type":"number"}, "y2":{"type":"number"},
@ -111,6 +119,23 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
description: "Open a workspace file or http(s) URL on the desktop.".into(), description: "Open a workspace file or http(s) URL on the desktop.".into(),
parameters: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}), parameters: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
}, },
ToolDefinition {
name: "browser".into(),
description: "Control Chromium through the page DOM. Prefer this over computer_act for anything in the browser. snapshot returns numbered elements and visible text (no screenshot). click/type/navigate by element id or CSS selector. The human still sees the live window.".into(),
parameters: json!({
"type":"object",
"properties":{
"action":{"type":"string","enum":["snapshot","click","type","press","navigate","wait"]},
"element":{"type":"number"},
"selector":{"type":"string"},
"text":{"type":"string"},
"key":{"type":"string"},
"url":{"type":"string"},
"ms":{"type":"number"}
},
"required":["action"]
}),
},
ToolDefinition { ToolDefinition {
name: "launch_app".into(), name: "launch_app".into(),
description: "Launch browser or terminal on the desktop.".into(), description: "Launch browser or terminal on the desktop.".into(),
@ -122,7 +147,7 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
}, },
ToolDefinition { ToolDefinition {
name: "request_takeover".into(), name: "request_takeover".into(),
description: "Ask the user to take over for passwords, 2FA, CAPTCHA, or protected input. Never ask them to paste secrets in chat.".into(), description: "Ask the user to take over for passwords, 2FA, CAPTCHA, login walls, or when the right on-screen control cannot be found. Never ask them to paste secrets in chat.".into(),
parameters: json!({"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"]}), parameters: json!({"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"]}),
}, },
]; ];
@ -173,6 +198,7 @@ pub async fn dispatch(ctx: &ToolCtx, name: &str, args: &Value) -> ToolOutcome {
match name { match name {
"computer_observe" => observe(ctx).await, "computer_observe" => observe(ctx).await,
"computer_act" => act(ctx, args).await, "computer_act" => act(ctx, args).await,
"browser" => browser(ctx, args).await,
"shell" => shell(ctx, args).await, "shell" => shell(ctx, args).await,
"list_files" => list_files(ctx, args).await, "list_files" => list_files(ctx, args).await,
"read_file" => read_file(ctx, args).await, "read_file" => read_file(ctx, args).await,
@ -211,13 +237,24 @@ async fn remember(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
return text_outcome("memory is disabled for this agent"); return text_outcome("memory is disabled for this agent");
} }
let input = CreateMemoryInput { let input = CreateMemoryInput {
content: args.get("content").and_then(Value::as_str).unwrap_or("").to_string(), content: args
importance: args.get("importance").and_then(Value::as_f64).unwrap_or(0.5) as f32, .get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
importance: args
.get("importance")
.and_then(Value::as_f64)
.unwrap_or(0.5) as f32,
session_id: Some(ctx.session_id.clone()), session_id: Some(ctx.session_id.clone()),
source_run_id: Some(ctx.run_id.clone()), source_run_id: Some(ctx.run_id.clone()),
source_message_id: None, source_message_id: None,
}; };
match ctx.memory.remember(&ctx.pool, &ctx.actor, &ctx.bot_id, input).await { match ctx
.memory
.remember(&ctx.pool, &ctx.actor, &ctx.bot_id, input)
.await
{
Ok(item) => text_outcome(json!({"ok":true,"memory":item}).to_string()), Ok(item) => text_outcome(json!({"ok":true,"memory":item}).to_string()),
Err(error) => text_outcome(error), Err(error) => text_outcome(error),
} }
@ -229,7 +266,11 @@ async fn recall_memory(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
} }
let query = args.get("query").and_then(Value::as_str).unwrap_or(""); let query = args.get("query").and_then(Value::as_str).unwrap_or("");
let limit = args.get("limit").and_then(Value::as_i64); let limit = args.get("limit").and_then(Value::as_i64);
match ctx.memory.recall(&ctx.pool, &ctx.actor, &ctx.bot_id, query, limit).await { match ctx
.memory
.recall(&ctx.pool, &ctx.actor, &ctx.bot_id, query, limit)
.await
{
Ok(items) => text_outcome(serde_json::to_string(&items).unwrap_or_else(|_| "[]".into())), Ok(items) => text_outcome(serde_json::to_string(&items).unwrap_or_else(|_| "[]".into())),
Err(error) => text_outcome(error), Err(error) => text_outcome(error),
} }
@ -239,17 +280,29 @@ async fn forget_memory(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if !ctx.memory_enabled { if !ctx.memory_enabled {
return text_outcome("memory is disabled for this agent"); return text_outcome("memory is disabled for this agent");
} }
let Some(id) = args.get("memory_id").and_then(Value::as_str).and_then(|id| Uuid::parse_str(id).ok()) else { let Some(id) = args
.get("memory_id")
.and_then(Value::as_str)
.and_then(|id| Uuid::parse_str(id).ok())
else {
return text_outcome("memory_id must be a UUID"); return text_outcome("memory_id must be a UUID");
}; };
match ctx.memory.forget(&ctx.pool, &ctx.actor, &ctx.bot_id, id).await { match ctx
.memory
.forget(&ctx.pool, &ctx.actor, &ctx.bot_id, id)
.await
{
Ok(deleted) => text_outcome(json!({"ok":deleted}).to_string()), Ok(deleted) => text_outcome(json!({"ok":deleted}).to_string()),
Err(error) => text_outcome(error), Err(error) => text_outcome(error),
} }
} }
fn text_outcome(text: impl Into<String>) -> ToolOutcome { fn text_outcome(text: impl Into<String>) -> ToolOutcome {
ToolOutcome { text: text.into(), image: None, pause: false } ToolOutcome {
text: text.into(),
image: None,
pause: false,
}
} }
fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> { fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
@ -272,9 +325,19 @@ fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
} }
fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bool) -> String { fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bool) -> String {
let label = if observation
.elements
.iter()
.any(|element| element.kind.as_deref() == Some("dom"))
{
"Clickable page elements"
} else {
"Clickable windows"
};
format!( format!(
"{note}{}\n{}", "{note}{}\n{label}: {}\n{}",
if unchanged { " (screen unchanged)" } else { "" }, if unchanged { " (screen unchanged)" } else { "" },
format_ui_elements(&observation.elements),
json!({ json!({
"frameId": observation.frame_id, "frameId": observation.frame_id,
"width": observation.width, "width": observation.width,
@ -282,6 +345,7 @@ fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bo
"capturedAt": observation.captured_at, "capturedAt": observation.captured_at,
"cursor": observation.cursor, "cursor": observation.cursor,
"activeWindow": observation.active_window, "activeWindow": observation.active_window,
"elements": observation.elements,
}) })
) )
} }
@ -291,7 +355,11 @@ async fn observe(ctx: &ToolCtx) -> ToolOutcome {
return blocked; return blocked;
} }
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await { match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => pack_observation(ctx, "computer observed", observation), Ok(observation) => {
let (observation, note) =
attach_page_elements(ctx, observation, "computer observed").await;
pack_observation(ctx, &note, observation)
}
Err(error) => ToolOutcome { Err(error) => ToolOutcome {
text: error.to_string(), text: error.to_string(),
image: None, image: None,
@ -300,10 +368,188 @@ async fn observe(ctx: &ToolCtx) -> ToolOutcome {
} }
} }
async fn attach_page_elements(
ctx: &ToolCtx,
mut observation: ComputerObservation,
note: &str,
) -> (ComputerObservation, String) {
let Some(page) = cdp_snapshot(ctx, false).await else {
return (observation, note.to_string());
};
observation.elements = merge_page_elements(observation.elements, &page.elements);
let mut note = note.to_string();
if !page.url.is_empty() || !page.title.is_empty() {
note.push_str(&format!("\nPage: {} {}", page.title, page.url));
}
if !page.text.is_empty() {
note.push_str("\nVisible text:\n");
note.push_str(&page.text);
}
(observation, note)
}
async fn cdp_snapshot(ctx: &ToolCtx, ensure: bool) -> Option<CdpPage> {
let page = cdp_call(ctx, json!({"action": "snapshot", "ensure": ensure})).await;
if page.ok { Some(page) } else { None }
}
async fn cdp_call(ctx: &ToolCtx, request: Value) -> CdpPage {
let display = ctx.context.display.as_deref().unwrap_or(":1");
let argv = cdp_command_on(display, ctx.context.profile_path.as_deref(), &request);
match ctx
.sandbox
.execute(
&ctx.computer,
CommandRequest {
argv,
cwd: None,
timeout_ms: Some(20_000),
},
&ctx.context,
)
.await
{
Ok(result) => {
let raw = if result.stdout.trim().is_empty() {
result.stderr
} else {
result.stdout
};
parse_cdp_page(&raw)
}
Err(error) => CdpPage {
ok: false,
error: Some(error.to_string()),
..CdpPage::default()
},
}
}
async fn browser(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
let action = args
.get("action")
.and_then(Value::as_str)
.unwrap_or("snapshot");
let mut request = json!({
"action": action,
"ensure": true,
});
for key in ["url", "text", "key", "ms", "selector"] {
if let Some(value) = args.get(key) {
request[key] = value.clone();
}
}
if request.get("selector").and_then(Value::as_str).is_none() {
if let Some(id) = args.get("element").and_then(Value::as_u64) {
let elements = ctx.elements.lock().unwrap().clone();
match elements
.iter()
.find(|element| u64::from(element.id) == id)
.and_then(|element| element.selector.clone())
{
Some(selector) => request["selector"] = json!(selector),
None if matches!(action, "click" | "type") => {
return pause_unknown_element(ctx, id as u32, &elements);
}
None => {}
}
}
}
if matches!(action, "click") && request.get("selector").and_then(Value::as_str).is_none() {
return text_outcome(
"browser click needs element id or selector. Call browser snapshot first.",
);
}
let page = cdp_call(ctx, request).await;
if !page.ok {
return text_outcome(page.error.unwrap_or_else(|| "browser failed".into()));
}
if !page.elements.is_empty() {
*ctx.elements.lock().unwrap() = page.elements.clone();
}
let text = browser_result_text(action, &page);
if action == "snapshot" {
return ToolOutcome {
text,
image: None,
pause: false,
};
}
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => {
let (observation, note) = attach_page_elements(ctx, observation, &text).await;
pack_observation(ctx, &note, observation)
}
Err(_) => ToolOutcome {
text,
image: None,
pause: false,
},
}
}
fn browser_result_text(action: &str, page: &CdpPage) -> String {
if matches!(action, "snapshot" | "navigate") {
format!(
"browser {action}\nPage: {} {}\nClickable page elements: {}\nVisible text:\n{}",
page.title,
page.url,
format_ui_elements(&page.elements),
page.text
)
} else {
format!("browser {action} ok")
}
}
async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome { async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) { if let Some(blocked) = vision_guard(ctx) {
return blocked; return blocked;
} }
let mut args = args.clone();
let elements = ctx.elements.lock().unwrap().clone();
if let Some(actions) = args.get_mut("actions") {
if let Err(error) = apply_element_targets(actions, &elements) {
if let ActionError::UnknownElement(id) = error {
return pause_unknown_element(ctx, id, &elements);
}
return text_outcome(error.to_string());
}
if let Some(items) = actions.as_array_mut() {
for item in items {
let Some(kind) = item.get("kind").and_then(Value::as_str) else {
continue;
};
if kind != "click" {
continue;
}
let Some(id) = item.get("element").and_then(Value::as_u64) else {
continue;
};
let Some(selector) = elements
.iter()
.find(|element| u64::from(element.id) == id)
.and_then(|element| element.selector.clone())
else {
continue;
};
let page = cdp_call(
ctx,
json!({"action":"click","selector":selector,"ensure":false}),
)
.await;
if page.ok {
if let Some(object) = item.as_object_mut() {
object.insert("kind".into(), json!("wait"));
object.insert("ms".into(), json!(80));
}
}
}
}
}
let actions = match parse_computer_actions(args.get("actions").unwrap_or(&Value::Null)) { let actions = match parse_computer_actions(args.get("actions").unwrap_or(&Value::Null)) {
Ok(actions) => actions, Ok(actions) => actions,
Err(error) => { Err(error) => {
@ -314,6 +560,7 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
}; };
} }
}; };
let had_click = actions.iter().any(action_is_click);
match ctx match ctx
.sandbox .sandbox
.act( .act(
@ -331,11 +578,15 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
{ {
Ok(result) => { Ok(result) => {
if let Some(observation) = result.observation { if let Some(observation) = result.observation {
pack_observation( let unchanged =
frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
let mut outcome = pack_observation(
ctx, ctx,
&format!("completed {} computer action(s)", result.completed), &format!("completed {} computer action(s)", result.completed),
observation, observation,
) );
note_click_result(ctx, had_click, unchanged, &mut outcome);
outcome
} else { } else {
ToolOutcome { ToolOutcome {
text: json!({"ok": true, "completed": result.completed}).to_string(), text: json!({"ok": true, "completed": result.completed}).to_string(),
@ -352,12 +603,59 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
} }
} }
fn action_is_click(action: &ComputerAction) -> bool {
matches!(
action,
ComputerAction::Pointer {
pointer_type: PointerType::Click | PointerType::Down,
..
}
)
}
fn pause_unknown_element(ctx: &ToolCtx, id: u32, elements: &[UiElement]) -> ToolOutcome {
*ctx.takeover_requested.lock().unwrap() = true;
ToolOutcome {
text: format!(
"找不到畫面上的元素 {id}。目前可見:{}。請接手操作,完成後釋放控制權,我會從目前畫面繼續。",
format_ui_elements(elements)
),
image: None,
pause: true,
}
}
fn note_click_result(ctx: &ToolCtx, had_click: bool, unchanged: bool, outcome: &mut ToolOutcome) {
if !had_click {
return;
}
let mut streak = ctx.miss_streak.lock().unwrap();
if unchanged {
*streak += 1;
*ctx.click_misses.lock().unwrap() += 1;
if *streak >= 2 {
*ctx.takeover_requested.lock().unwrap() = true;
outcome.pause = true;
outcome.text.push_str(
"\n連續兩次點擊後畫面沒有變化。請接手確認,完成後釋放控制權,我會從目前畫面繼續。",
);
}
} else {
*streak = 0;
}
}
fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome { fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome {
let unchanged = frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation); let unchanged = frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
*ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone()); *ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone());
*ctx.elements.lock().unwrap() = observation.elements.clone();
ToolOutcome { ToolOutcome {
text: observation_text(note, &observation, unchanged), text: observation_text(note, &observation, unchanged),
image: if unchanged { None } else { Some(observation.image) }, image: if unchanged {
None
} else {
Some(overlay_elements(&observation.image, &observation.elements))
},
pause: false, pause: false,
} }
} }
@ -406,7 +704,11 @@ async fn list_files(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
}; };
} }
}; };
match ctx.sandbox.list_files(&ctx.computer, &stored, &ctx.context).await { match ctx
.sandbox
.list_files(&ctx.computer, &stored, &ctx.context)
.await
{
Ok(entries) => ToolOutcome { Ok(entries) => ToolOutcome {
text: serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()), text: serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()),
image: None, image: None,
@ -432,7 +734,11 @@ async fn read_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
}; };
} }
}; };
match ctx.sandbox.read_file(&ctx.computer, &stored, &ctx.context).await { match ctx
.sandbox
.read_file(&ctx.computer, &stored, &ctx.context)
.await
{
Ok(bytes) => ToolOutcome { Ok(bytes) => ToolOutcome {
text: String::from_utf8_lossy(&bytes).into_owned(), text: String::from_utf8_lossy(&bytes).into_owned(),
image: None, image: None,
@ -447,7 +753,10 @@ async fn read_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
} }
async fn write_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome { async fn write_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let requested = args.get("path").and_then(Value::as_str).unwrap_or("notes.txt"); let requested = args
.get("path")
.and_then(Value::as_str)
.unwrap_or("notes.txt");
let content = args.get("content").and_then(Value::as_str).unwrap_or(""); let content = args.get("content").and_then(Value::as_str).unwrap_or("");
let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) { let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) {
Ok(path) => path, Ok(path) => path,
@ -520,7 +829,10 @@ async fn launch_app(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) { if let Some(blocked) = vision_guard(ctx) {
return blocked; return blocked;
} }
let application = args.get("application").and_then(Value::as_str).unwrap_or("browser"); let application = args
.get("application")
.and_then(Value::as_str)
.unwrap_or("browser");
let uri = args.get("uri").and_then(Value::as_str).map(str::to_string); let uri = args.get("uri").and_then(Value::as_str).map(str::to_string);
match ctx match ctx
.sandbox .sandbox

234
crates/api/src/workspace.rs Normal file
View File

@ -0,0 +1,234 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use lazyboy_contracts::{ModelProvider, catalog_models, default_model_id};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::db::Actor;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/workspace/settings",
get(get_settings).patch(update_settings),
)
.route("/api/workspace/models", get(list_models))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProviderInfo {
id: &'static str,
name: &'static str,
needs_base_url: bool,
needs_key: bool,
default_base_url: Option<&'static str>,
default_model: Option<&'static str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelChoice {
id: String,
name: String,
}
fn provider_info(provider: ModelProvider) -> ProviderInfo {
ProviderInfo {
id: provider.as_str(),
name: match provider {
ModelProvider::Xai => "xAI",
ModelProvider::OpencodeGo => "OpenCode Go",
ModelProvider::OpenaiCompatible => "OpenAI 相容",
ModelProvider::Openai => "OpenAI",
ModelProvider::Anthropic => "Anthropic",
ModelProvider::Openrouter => "OpenRouter",
},
needs_base_url: provider.requires_base_url(),
needs_key: provider.requires_api_key(),
default_base_url: provider.default_base_url(),
default_model: default_model_id(provider),
}
}
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
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)?;
let provider = space
.default_model_provider
.parse::<ModelProvider>()
.unwrap_or(ModelProvider::Xai);
let env_key_set = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty())
.is_some();
Ok(Json(json!({
"provider": provider.as_str(),
"modelId": space.default_model_id,
"baseUrl": space.default_model_base_url.unwrap_or_default(),
"apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
"envKeySet": env_key_set,
"envKeyName": provider.env_key_name(),
"providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(),
"models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(),
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateSettings {
provider: String,
model_id: String,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
clear_api_key: bool,
}
async fn update_settings(
State(state): State<AppState>,
Json(input): Json<UpdateSettings>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider = input
.provider
.parse::<ModelProvider>()
.map_err(|_| StatusCode::BAD_REQUEST)?;
if !ModelProvider::selectable().contains(&provider) {
return Err(StatusCode::BAD_REQUEST);
}
let model_id = input.model_id.trim();
if model_id.is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let base_url = input
.base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if provider.requires_base_url() && base_url.is_none() {
return Err(StatusCode::BAD_REQUEST);
}
let api_key = if input.clear_api_key {
Some(None)
} else {
input
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(Some)
};
state
.db
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_settings(State(state)).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelsQuery {
provider: String,
#[serde(default)]
base_url: Option<String>,
}
async fn list_models(
State(state): State<AppState>,
Query(query): Query<ModelsQuery>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider = query
.provider
.parse::<ModelProvider>()
.map_err(|_| StatusCode::BAD_REQUEST)?;
let space = state.db.get_space(&actor).await.ok().flatten();
let fallback = catalog_models(provider)
.iter()
.map(|(id, name)| ModelChoice {
id: (*id).into(),
name: (*name).into(),
})
.collect::<Vec<_>>();
let env_key = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty());
let live = fetch_remote_models(
provider,
query.base_url.as_deref().or(space
.as_ref()
.and_then(|row| row.default_model_base_url.as_deref())),
space
.as_ref()
.and_then(|row| row.default_model_api_key.as_deref())
.or(env_key.as_deref()),
)
.await
.unwrap_or_default();
let models = if live.is_empty() { fallback } else { live };
Ok(Json(json!({ "models": models })))
}
async fn fetch_remote_models(
provider: ModelProvider,
base_url: Option<&str>,
api_key: Option<&str>,
) -> Result<Vec<ModelChoice>, String> {
let base = base_url
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_end_matches('/').to_string())
.or_else(|| provider.default_base_url().map(str::to_string))
.ok_or_else(|| "missing base URL".to_string())?;
let url = format!("{base}/models");
let mut request = reqwest::Client::new().get(url);
if let Some(key) = api_key.filter(|value| !value.is_empty()) {
request = request.bearer_auth(key);
}
let body: Value = request
.send()
.await
.map_err(|error| error.to_string())?
.error_for_status()
.map_err(|error| error.to_string())?
.json()
.await
.map_err(|error| error.to_string())?;
let items = body
.get("data")
.and_then(Value::as_array)
.cloned()
.or_else(|| body.as_array().cloned())
.unwrap_or_default();
Ok(items
.into_iter()
.filter_map(|item| {
let id = item.get("id")?.as_str()?.to_string();
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or(&id)
.to_string();
Some(ModelChoice { id, name })
})
.collect())
}

View File

@ -77,6 +77,32 @@ pub struct ActiveWindow {
pub title: Option<String>, pub title: Option<String>,
} }
/// A numbered on-screen target the model can click without guessing pixels.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct UiElement {
pub id: u32,
pub title: String,
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
/// CSS selector for in-page controls (Chromium CDP). Native windows leave this empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// "dom" for page controls, "window" for native windows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
}
impl UiElement {
pub fn center(&self) -> (u32, u32) {
(
self.x.saturating_add(self.w / 2),
self.y.saturating_add(self.h / 2),
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ComputerObservation { pub struct ComputerObservation {
pub frame_id: String, pub frame_id: String,
@ -88,11 +114,13 @@ pub struct ComputerObservation {
pub height: u32, pub height: u32,
pub cursor: Option<CursorPosition>, pub cursor: Option<CursorPosition>,
pub active_window: Option<ActiveWindow>, pub active_window: Option<ActiveWindow>,
#[serde(default)]
pub elements: Vec<UiElement>,
} }
mod serde_bytes_opt { mod serde_bytes_opt {
use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Deserializer, Serializer}; use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> { pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{ComputerMode, ModelProvider}; use crate::{ComputerMode, ModelProvider};

View File

@ -197,8 +197,10 @@ pub const DEFAULT_SCREEN_HEIGHT: u32 = 800;
pub const TEAM_SCREEN_LIMIT: u32 = 8; pub const TEAM_SCREEN_LIMIT: u32 = 8;
pub const MULTI_SCREEN_UNAVAILABLE: &str = "This computer does not support multiple screens. Desktop tools are already in use on the shared display. File and shell tools still work."; pub const MULTI_SCREEN_UNAVAILABLE: &str = "This computer does not support multiple screens. Desktop tools are already in use on the shared display. File and shell tools still work.";
pub const TEAM_SCREENS_FULL: &str = "This Team computer has no free screens left. File and shell tools still work."; pub const TEAM_SCREENS_FULL: &str =
pub const PROFILE_LOCKED: &str = "Another bot is using this shared browser profile. File and shell tools still work."; "This Team computer has no free screens left. File and shell tools still work.";
pub const PROFILE_LOCKED: &str =
"Another bot is using this shared browser profile. File and shell tools still work.";
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@ -2,9 +2,11 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "kebab-case")]
pub enum ModelProvider { pub enum ModelProvider {
Xai, Xai,
OpencodeGo,
OpenaiCompatible,
Openai, Openai,
Anthropic, Anthropic,
Openrouter, Openrouter,
@ -14,6 +16,8 @@ impl ModelProvider {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Xai => "xai", Self::Xai => "xai",
Self::OpencodeGo => "opencode-go",
Self::OpenaiCompatible => "openai-compatible",
Self::Openai => "openai", Self::Openai => "openai",
Self::Anthropic => "anthropic", Self::Anthropic => "anthropic",
Self::Openrouter => "openrouter", Self::Openrouter => "openrouter",
@ -23,21 +27,36 @@ impl ModelProvider {
pub fn env_key_name(self) -> &'static str { pub fn env_key_name(self) -> &'static str {
match self { match self {
Self::Xai => "XAI_API_KEY", Self::Xai => "XAI_API_KEY",
Self::Openai => "OPENAI_API_KEY", Self::OpencodeGo => "OPENCODE_GO_API_KEY",
Self::OpenaiCompatible | Self::Openai => "OPENAI_API_KEY",
Self::Anthropic => "ANTHROPIC_API_KEY", Self::Anthropic => "ANTHROPIC_API_KEY",
Self::Openrouter => "OPENROUTER_API_KEY", Self::Openrouter => "OPENROUTER_API_KEY",
} }
} }
pub fn requires_api_key(self) -> bool {
!matches!(self, Self::OpenaiCompatible)
}
pub fn requires_base_url(self) -> bool {
matches!(self, Self::OpenaiCompatible)
}
/// First-party OpenAI-compatible base URL when we own the client mapping. /// First-party OpenAI-compatible base URL when we own the client mapping.
pub fn default_base_url(self) -> Option<&'static str> { pub fn default_base_url(self) -> Option<&'static str> {
match self { match self {
Self::Xai => Some("https://api.x.ai/v1"), Self::Xai => Some("https://api.x.ai/v1"),
Self::OpencodeGo => Some("https://opencode.ai/zen/go/v1"),
Self::OpenaiCompatible => None,
Self::Openai => Some("https://api.openai.com/v1"), Self::Openai => Some("https://api.openai.com/v1"),
Self::Anthropic => None, Self::Anthropic => None,
Self::Openrouter => Some("https://openrouter.ai/api/v1"), Self::Openrouter => Some("https://openrouter.ai/api/v1"),
} }
} }
pub fn selectable() -> &'static [Self] {
&[Self::Xai, Self::OpencodeGo, Self::OpenaiCompatible]
}
} }
impl std::str::FromStr for ModelProvider { impl std::str::FromStr for ModelProvider {
@ -46,6 +65,8 @@ impl std::str::FromStr for ModelProvider {
fn from_str(value: &str) -> Result<Self, Self::Err> { fn from_str(value: &str) -> Result<Self, Self::Err> {
match value { match value {
"xai" => Ok(Self::Xai), "xai" => Ok(Self::Xai),
"opencode-go" | "opencodego" => Ok(Self::OpencodeGo),
"openai-compatible" | "openai_compatible" | "local" => Ok(Self::OpenaiCompatible),
"openai" => Ok(Self::Openai), "openai" => Ok(Self::Openai),
"anthropic" => Ok(Self::Anthropic), "anthropic" => Ok(Self::Anthropic),
"openrouter" => Ok(Self::Openrouter), "openrouter" => Ok(Self::Openrouter),
@ -64,33 +85,74 @@ pub struct ModelCapabilities {
} }
pub const DEFAULT_XAI_MODEL: &str = "grok-4.6"; pub const DEFAULT_XAI_MODEL: &str = "grok-4.6";
pub const DEFAULT_OPENCODE_GO_MODEL: &str = "glm-5.1";
pub fn default_model_id(provider: ModelProvider) -> Option<&'static str> { pub fn default_model_id(provider: ModelProvider) -> Option<&'static str> {
match provider { match provider {
ModelProvider::Xai => Some(DEFAULT_XAI_MODEL), ModelProvider::Xai => Some(DEFAULT_XAI_MODEL),
ModelProvider::Openai | ModelProvider::Anthropic | ModelProvider::Openrouter => None, ModelProvider::OpencodeGo => Some(DEFAULT_OPENCODE_GO_MODEL),
ModelProvider::OpenaiCompatible
| ModelProvider::Openai
| ModelProvider::Anthropic
| ModelProvider::Openrouter => None,
}
}
pub fn catalog_models(provider: ModelProvider) -> &'static [(&'static str, &'static str)] {
match provider {
ModelProvider::Xai => &[
("grok-4.6", "Grok 4.6"),
("grok-4.5", "Grok 4.5"),
("grok-4", "Grok 4"),
("grok-3", "Grok 3"),
("grok-3-mini", "Grok 3 Mini"),
("grok-3-fast", "Grok 3 Fast"),
],
ModelProvider::OpencodeGo => &[
("glm-5.1", "GLM-5.1"),
("glm-5", "GLM-5"),
("glm-5.2", "GLM-5.2"),
("kimi-k2.6", "Kimi K2.6"),
("kimi-k2.5", "Kimi K2.5"),
("kimi-k2.7-code", "Kimi K2.7 Code"),
("deepseek-v4-flash", "DeepSeek V4 Flash"),
("deepseek-v4-pro", "DeepSeek V4 Pro"),
("mimo-v2.5-pro", "MiMo V2.5 Pro"),
("minimax-m2.7", "MiniMax M2.7"),
("qwen3.6-plus", "Qwen3.6 Plus"),
],
ModelProvider::OpenaiCompatible
| ModelProvider::Openai
| ModelProvider::Anthropic
| ModelProvider::Openrouter => &[],
} }
} }
/// Best-effort catalog. Unknown ids for a wired provider default to "no vision" /// Best-effort catalog. Unknown ids for a wired provider default to "no vision"
/// so we never shove screenshots at a model that may not accept them. /// so we never shove screenshots at a model that may not accept them.
pub fn model_capabilities(provider: ModelProvider, model_id: &str) -> ModelCapabilities { pub fn model_capabilities(provider: ModelProvider, model_id: &str) -> ModelCapabilities {
let id = model_id.to_ascii_lowercase();
let vision = match provider { let vision = match provider {
ModelProvider::Xai => { ModelProvider::Xai => {
let id = model_id.to_ascii_lowercase();
id.starts_with("grok-4") id.starts_with("grok-4")
|| id.contains("vision") || id.contains("vision")
|| id.starts_with("grok-2") || id.starts_with("grok-2")
|| id.starts_with("grok-3") || id.starts_with("grok-3")
} }
ModelProvider::Openai => { ModelProvider::OpencodeGo => id.contains("vision") || id.contains("omni"),
let id = model_id.to_ascii_lowercase(); ModelProvider::OpenaiCompatible | ModelProvider::Openai => {
id.contains("gpt-4o") || id.contains("gpt-5") || id.contains("vision") id.contains("gpt-4o")
|| id.contains("gpt-5")
|| id.contains("vision")
|| id.contains("llava")
|| id.contains("omni")
} }
ModelProvider::Anthropic => model_id.to_ascii_lowercase().contains("claude"), ModelProvider::Anthropic => id.contains("claude"),
ModelProvider::Openrouter => { ModelProvider::Openrouter => {
let id = model_id.to_ascii_lowercase(); id.contains("vision")
id.contains("vision") || id.contains("gpt-4o") || id.contains("claude") || id.contains("grok") || id.contains("gpt-4o")
|| id.contains("claude")
|| id.contains("grok")
} }
}; };
ModelCapabilities { vision } ModelCapabilities { vision }

View File

@ -30,7 +30,11 @@ impl RunStatus {
pub fn is_active(self) -> bool { pub fn is_active(self) -> bool {
matches!( matches!(
self, self,
Self::Queued | Self::Leased | Self::Running | Self::WaitingInput | Self::WaitingTakeover Self::Queued
| Self::Leased
| Self::Running
| Self::WaitingInput
| Self::WaitingTakeover
) )
} }
@ -52,8 +56,14 @@ impl RunStatus {
| Self::Cancelled | Self::Cancelled
| Self::Leased | Self::Leased
) )
| (Self::WaitingInput, Self::Queued | Self::Leased | Self::Cancelled) | (
| (Self::WaitingTakeover, Self::Queued | Self::Leased | Self::Cancelled) Self::WaitingInput,
Self::Queued | Self::Leased | Self::Cancelled
)
| (
Self::WaitingTakeover,
Self::Queued | Self::Leased | Self::Cancelled
)
| (Self::Failed, Self::Queued) | (Self::Failed, Self::Queued)
) )
} }

View File

@ -14,3 +14,4 @@ sha2.workspace = true
hex.workspace = true hex.workspace = true
chrono.workspace = true chrono.workspace = true
async-trait = "0.1" async-trait = "0.1"
image.workspace = true

View File

@ -1,5 +1,5 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection}; use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection, UiElement};
use serde_json::Value; use serde_json::{Value, json};
use thiserror::Error; use thiserror::Error;
pub const MAX_COMPUTER_ACTIONS: usize = 24; pub const MAX_COMPUTER_ACTIONS: usize = 24;
@ -18,6 +18,30 @@ pub enum ActionError {
Unsupported(String), Unsupported(String),
#[error("computer action {0} must be a non-negative coordinate")] #[error("computer action {0} must be a non-negative coordinate")]
BadCoordinate(&'static str), BadCoordinate(&'static str),
#[error("computer action element {0} is not on the current screen")]
UnknownElement(u32),
}
/// Fill x/y from a numbered on-screen element so the model can click by id.
pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Result<(), ActionError> {
let Some(items) = value.as_array_mut() else {
return Ok(());
};
for raw in items {
let Some(action) = raw.as_object_mut() else {
continue;
};
let Some(id) = action.get("element").and_then(Value::as_u64) else {
continue;
};
let Some(element) = elements.iter().find(|element| u64::from(element.id) == id) else {
return Err(ActionError::UnknownElement(id as u32));
};
let (x, y) = element.center();
action.insert("x".into(), json!(x));
action.insert("y".into(), json!(y));
}
Ok(())
} }
pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> { pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
@ -61,7 +85,8 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
pointer_type, pointer_type,
button: Some(button), button: Some(button),
}; };
let doubled = action.get("double").and_then(Value::as_bool) == Some(true) && kind == "click"; let doubled =
action.get("double").and_then(Value::as_bool) == Some(true) && kind == "click";
actions.push(pointer.clone()); actions.push(pointer.clone());
if doubled { if doubled {
actions.push(ComputerAction::Wait { ms: 70 }); actions.push(ComputerAction::Wait { ms: 70 });
@ -83,11 +108,17 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
let x = coordinate(action.get("x"), "x")?; let x = coordinate(action.get("x"), "x")?;
let y = coordinate(action.get("y"), "y")?; let y = coordinate(action.get("y"), "y")?;
let to_x = coordinate( let to_x = coordinate(
action.get("x2").or_else(|| action.get("toX")).or_else(|| action.get("to_x")), action
.get("x2")
.or_else(|| action.get("toX"))
.or_else(|| action.get("to_x")),
"x2", "x2",
)?; )?;
let to_y = coordinate( let to_y = coordinate(
action.get("y2").or_else(|| action.get("toY")).or_else(|| action.get("to_y")), action
.get("y2")
.or_else(|| action.get("toY"))
.or_else(|| action.get("to_y")),
"y2", "y2",
)?; )?;
let button = if action.get("button").and_then(Value::as_str) == Some("right") { let button = if action.get("button").and_then(Value::as_str) == Some("right") {
@ -130,13 +161,16 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let modifiers = action.get("modifiers").and_then(Value::as_array).map(|items| { let modifiers = action
items .get("modifiers")
.iter() .and_then(Value::as_array)
.filter_map(Value::as_str) .map(|items| {
.map(str::to_string) items
.collect() .iter()
}); .filter_map(Value::as_str)
.map(str::to_string)
.collect()
});
actions.push(ComputerAction::Key { key, modifiers }); actions.push(ComputerAction::Key { key, modifiers });
} }
"scroll" => { "scroll" => {
@ -174,11 +208,13 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
ms: bounded(action.get("ms"), 0, 1_200, 80), ms: bounded(action.get("ms"), 0, 1_200, 80),
}); });
} }
other => return Err(ActionError::Unsupported(if other.is_empty() { other => {
"(missing)".to_string() return Err(ActionError::Unsupported(if other.is_empty() {
} else { "(missing)".to_string()
other.to_string() } else {
})), other.to_string()
}));
}
} }
} }
if actions.len() > MAX_COMPUTER_ACTIONS { if actions.len() > MAX_COMPUTER_ACTIONS {
@ -253,7 +289,8 @@ mod tests {
#[test] #[test]
fn scroll_defaults_to_a_real_page_chunk() { fn scroll_defaults_to_a_real_page_chunk() {
let actions = parse_computer_actions(&json!([{"kind": "scroll", "direction": "down"}])).unwrap(); let actions =
parse_computer_actions(&json!([{"kind": "scroll", "direction": "down"}])).unwrap();
match &actions[0] { match &actions[0] {
ComputerAction::Scroll { amount, .. } => assert_eq!(*amount, Some(12)), ComputerAction::Scroll { amount, .. } => assert_eq!(*amount, Some(12)),
other => panic!("{other:?}"), other => panic!("{other:?}"),
@ -262,11 +299,45 @@ mod tests {
#[test] #[test]
fn rejects_empty_and_overflow() { fn rejects_empty_and_overflow() {
assert_eq!(parse_computer_actions(&json!([])).unwrap_err(), ActionError::Empty); assert_eq!(
parse_computer_actions(&json!([])).unwrap_err(),
ActionError::Empty
);
let too_many: Vec<_> = (0..25).map(|_| json!({"kind": "wait", "ms": 1})).collect(); let too_many: Vec<_> = (0..25).map(|_| json!({"kind": "wait", "ms": 1})).collect();
assert_eq!( assert_eq!(
parse_computer_actions(&json!(too_many)).unwrap_err(), parse_computer_actions(&json!(too_many)).unwrap_err(),
ActionError::TooMany ActionError::TooMany
); );
} }
#[test]
fn element_id_fills_click_coordinates() {
let mut actions = json!([{"kind": "click", "element": 1}]);
let elements = vec![UiElement {
id: 1,
title: "Terminal".into(),
x: 10,
y: 20,
w: 100,
h: 40,
..UiElement::default()
}];
apply_element_targets(&mut actions, &elements).unwrap();
assert_eq!(actions[0]["x"], 60);
assert_eq!(actions[0]["y"], 40);
let parsed = parse_computer_actions(&actions).unwrap();
assert!(matches!(
parsed[0],
ComputerAction::Pointer { x: 60, y: 40, .. }
));
}
#[test]
fn unknown_element_errors() {
let mut actions = json!([{"kind": "click", "element": 9}]);
assert_eq!(
apply_element_targets(&mut actions, &[]).unwrap_err(),
ActionError::UnknownElement(9)
);
}
} }

362
crates/control/src/cdp.py Normal file
View File

@ -0,0 +1,362 @@
import json, os, sys, time, socket, base64, struct, subprocess, urllib.request
def fail(msg):
print(json.dumps({"ok": False, "error": msg}))
sys.exit(0)
def http_json(url, timeout=2):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception:
return None
class Ws:
def __init__(self, url):
rest = url[5:]
hostpath = rest.split("/", 1)
hostport = hostpath[0]
path = "/" + (hostpath[1] if len(hostpath) > 1 else "")
if ":" in hostport:
host, port = hostport.rsplit(":", 1)
port = int(port)
else:
host, port = hostport, 80
self.sock = socket.create_connection((host, port), 5)
key = base64.b64encode(os.urandom(16)).decode()
req = (
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\nOrigin: http://%s\r\n\r\n"
% (path, hostport, key, hostport)
)
self.sock.sendall(req.encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("ws handshake closed")
buf += chunk
self.n = 0
def _frame(self, data):
n = len(data)
hdr = bytearray([0x81])
if n < 126:
hdr.append(0x80 | n)
elif n < 65536:
hdr.append(0x80 | 126)
hdr += struct.pack("!H", n)
else:
hdr.append(0x80 | 127)
hdr += struct.pack("!Q", n)
mask = os.urandom(4)
hdr += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return bytes(hdr) + masked
def _read(self, n):
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise RuntimeError("ws eof")
buf += chunk
return buf
def _read_frame(self):
hdr = self._read(2)
opcode = hdr[0] & 0x0F
masked = hdr[1] & 0x80
n = hdr[1] & 0x7F
if n == 126:
n = struct.unpack("!H", self._read(2))[0]
elif n == 127:
n = struct.unpack("!Q", self._read(8))[0]
mask = self._read(4) if masked else b""
payload = self._read(n)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
def recv_json(self):
while True:
opcode, payload = self._read_frame()
if opcode == 0x8:
raise RuntimeError("ws closed")
if opcode == 0x9:
continue
if opcode in (0x1, 0x2):
return json.loads(payload.decode())
def call(self, method, params=None):
self.n += 1
msg = {"id": self.n, "method": method}
if params:
msg["params"] = params
self.sock.sendall(self._frame(json.dumps(msg).encode()))
while True:
obj = self.recv_json()
if obj.get("id") == self.n:
if "error" in obj:
raise RuntimeError(str(obj["error"]))
return obj.get("result") or {}
def close(self):
try:
self.sock.close()
except Exception:
pass
def probe(port):
return http_json("http://127.0.0.1:%s/json/version" % port) is not None
def kill_profile(profile):
if not profile:
return
try:
out = subprocess.check_output(["pgrep", "-af", "chromium"], text=True, stderr=subprocess.DEVNULL)
except Exception:
return
for line in out.splitlines():
if profile not in line or "--type=" in line:
continue
try:
os.kill(int(line.split()[0]), 15)
except Exception:
pass
def spawn_browser(display, profile, port):
env = os.environ.copy()
env["DISPLAY"] = display
if profile:
env["LAZYBOY_BROWSER_PROFILE"] = profile
subprocess.Popen(
["lazyboy-browser", "--remote-debugging-port=%s" % port, "--remote-allow-origins=*"],
env=env,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def connect(port):
tabs = http_json("http://127.0.0.1:%s/json/list" % port) or []
pages = [t for t in tabs if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
if not pages:
fail("no browser tab")
pages.sort(key=lambda t: (t.get("url") or "").startswith("chrome://"), reverse=False)
ws = Ws(pages[0]["webSocketDebuggerUrl"])
ws.call("Runtime.enable")
ws.call("Page.enable")
return ws
SNAP_JS = r"""
(() => {
const sels = 'a, button, input, textarea, select, summary, label, [role="button"], [role="link"], [role="textbox"], [role="checkbox"], [role="menuitem"], [contenteditable="true"]';
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx0 = (window.screenX || 0) + Math.floor(chromeW / 2);
const sy0 = (window.screenY || 0) + chromeH;
const seen = new Set();
const out = [];
let n = 1;
for (const el of document.querySelectorAll(sels)) {
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue;
if (r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth) continue;
const st = getComputedStyle(el);
if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
const text = (el.innerText || el.value || el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.getAttribute("name") || el.tagName)
.replace(/\s+/g, " ").trim().slice(0, 80);
if (!text) continue;
const key = [el.tagName, text, Math.round(r.x), Math.round(r.y)].join("|");
if (seen.has(key)) continue;
seen.add(key);
el.setAttribute("data-lazyboy", String(n));
out.push({
id: n,
title: text,
tag: el.tagName.toLowerCase(),
selector: '[data-lazyboy="' + n + '"]',
kind: "dom",
x: Math.max(0, Math.round(sx0 + r.x)),
y: Math.max(0, Math.round(sy0 + r.y)),
w: Math.round(r.width),
h: Math.round(r.height)
});
n += 1;
if (out.length >= 50) break;
}
const body = (document.body && document.body.innerText || "").replace(/\s+/g, " ").trim().slice(0, 3000);
return {url: location.href, title: document.title || "", text: body, elements: out};
})()
"""
CLICK_JS = r"""
(sel) => {
const el = document.querySelector(sel);
if (!el) return {ok: false, error: "element gone"};
el.scrollIntoView({block: "center", inline: "nearest"});
const r = el.getBoundingClientRect();
el.focus();
el.click();
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx = (window.screenX || 0) + Math.floor(chromeW / 2) + r.x + r.width / 2;
const sy = (window.screenY || 0) + chromeH + r.y + r.height / 2;
return {ok: true, x: Math.round(sx), y: Math.round(sy)};
}
"""
def evaluate(ws, expression, args=None):
params = {"expression": expression, "returnByValue": True, "awaitPromise": True}
if args is not None:
params = {
"expression": "(%s)(%s)" % (expression, json.dumps(args)),
"returnByValue": True,
"awaitPromise": True,
}
result = ws.call("Runtime.evaluate", params)
val = (result.get("result") or {}).get("value")
if result.get("exceptionDetails"):
raise RuntimeError(str(result["exceptionDetails"]))
return val
def snapshot(ws):
val = evaluate(ws, SNAP_JS) or {}
return {
"ok": True,
"action": "snapshot",
"url": val.get("url") or "",
"title": val.get("title") or "",
"text": val.get("text") or "",
"elements": val.get("elements") or [],
}
def pointer(display, x, y):
try:
subprocess.check_call(
["env", "DISPLAY=%s" % display, "xdotool", "mousemove", "--sync", "--", str(int(x)), str(int(y))],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
KEYS = {
"Return": (13, "Enter", "Enter"),
"Enter": (13, "Enter", "Enter"),
"Tab": (9, "Tab", "Tab"),
"BackSpace": (8, "Backspace", "Backspace"),
"Backspace": (8, "Backspace", "Backspace"),
"Escape": (27, "Escape", "Escape"),
"Esc": (27, "Escape", "Escape"),
"Space": (32, " ", "Space"),
}
def press(ws, key):
spec = KEYS.get(key) or KEYS.get(key.title())
if spec:
code, name, key_id = spec
for typ in ("keyDown", "keyUp"):
ws.call("Input.dispatchKeyEvent", {
"type": typ,
"windowsVirtualKeyCode": code,
"key": name,
"code": key_id,
})
return
ws.call("Input.dispatchKeyEvent", {"type": "keyDown", "text": key[:1]})
ws.call("Input.dispatchKeyEvent", {"type": "keyUp", "text": key[:1]})
def main():
req = json.loads(sys.argv[1])
action = req.get("action") or "snapshot"
display = req.get("display") or ":1"
profile = req.get("profile") or ""
port = int(req.get("port") or 9222)
ensure = bool(req.get("ensure"))
if action == "probe":
print(json.dumps({"ok": probe(port)}))
return
if not probe(port):
if not ensure:
fail("cdp unavailable")
kill_profile(profile)
time.sleep(0.4)
spawn_browser(display, profile, port)
ready = False
for _ in range(24):
time.sleep(0.25)
if probe(port):
ready = True
break
if not ready:
fail("cdp unavailable")
restarted = True
else:
restarted = False
if action == "ensure":
print(json.dumps({"ok": True, "restarted": restarted}))
return
ws = connect(port)
try:
if action == "snapshot":
body = snapshot(ws)
body["restarted"] = restarted
print(json.dumps(body))
return
if action == "navigate":
url = req.get("url") or ""
if not url:
fail("url required")
ws.call("Page.navigate", {"url": url})
time.sleep(1.2)
body = snapshot(ws)
body["restarted"] = restarted
print(json.dumps(body))
return
if action == "click":
sel = req.get("selector") or ""
if not sel:
fail("selector required")
val = evaluate(ws, CLICK_JS, sel) or {}
if not val.get("ok"):
fail(val.get("error") or "click failed")
pointer(display, val.get("x") or 0, val.get("y") or 0)
print(json.dumps({"ok": True, "action": "click", "selector": sel, "restarted": restarted}))
return
if action == "type":
sel = req.get("selector") or ""
text = req.get("text") or ""
if sel:
val = evaluate(ws, CLICK_JS, sel) or {}
if val.get("ok"):
pointer(display, val.get("x") or 0, val.get("y") or 0)
if text:
ws.call("Input.insertText", {"text": text})
print(json.dumps({"ok": True, "action": "type", "restarted": restarted}))
return
if action == "press":
key = req.get("key") or "Return"
press(ws, key)
print(json.dumps({"ok": True, "action": "press", "key": key, "restarted": restarted}))
return
if action == "wait":
ms = min(max(int(req.get("ms") or 400), 0), 5000)
time.sleep(ms / 1000.0)
print(json.dumps({"ok": True, "action": "wait", "ms": ms}))
return
fail("unsupported action")
finally:
ws.close()
if __name__ == "__main__":
try:
main()
except Exception as e:
fail(str(e))

203
crates/control/src/cdp.rs Normal file
View File

@ -0,0 +1,203 @@
use lazyboy_contracts::UiElement;
use serde_json::{Value, json};
use crate::x11::parse_ui_elements;
use crate::{PRIMARY_DISPLAY, normalize_display};
const CDP_PY: &str = include_str!("cdp.py");
pub fn devtools_port(display: &str) -> u16 {
let number = normalize_display(display)
.trim_start_matches(':')
.parse::<u16>()
.unwrap_or(1)
.max(1);
9221 + number
}
pub fn cdp_command_on(display: &str, profile: Option<&str>, request: &Value) -> Vec<String> {
let mut body = request.clone();
if let Some(object) = body.as_object_mut() {
object
.entry("display")
.or_insert_with(|| json!(normalize_display(display)));
object
.entry("port")
.or_insert_with(|| json!(devtools_port(display)));
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
object.entry("profile").or_insert_with(|| json!(profile));
}
}
vec![
"env".into(),
format!("DISPLAY={}", normalize_display(display)),
"python3".into(),
"-c".into(),
CDP_PY.into(),
body.to_string(),
]
}
pub fn cdp_command(request: &Value) -> Vec<String> {
cdp_command_on(PRIMARY_DISPLAY, None, request)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CdpPage {
pub ok: bool,
pub error: Option<String>,
pub url: String,
pub title: String,
pub text: String,
pub restarted: bool,
pub elements: Vec<UiElement>,
}
pub fn parse_cdp_page(raw: &str) -> CdpPage {
let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
if value.get("ok").and_then(Value::as_bool) != Some(true) {
return CdpPage {
ok: false,
error: value
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| Some("cdp unavailable".into())),
..CdpPage::default()
};
}
CdpPage {
ok: true,
error: None,
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
title: value
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
text: value
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
restarted: value.get("restarted").and_then(Value::as_bool) == Some(true),
elements: value
.get("elements")
.map(|items| parse_ui_elements(&items.to_string()))
.unwrap_or_default(),
}
}
pub fn merge_page_elements(windows: Vec<UiElement>, page: &[UiElement]) -> Vec<UiElement> {
if page.is_empty() {
return windows;
}
let mut elements = page.to_vec();
let mut next = elements.len() as u32 + 1;
for mut window in windows {
let title = window.title.to_lowercase();
if title.contains("chromium") || title.contains("chrome") {
continue;
}
window.id = next;
next += 1;
elements.push(window);
}
elements
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_port_follows_the_display() {
assert_eq!(devtools_port(":1"), 9222);
assert_eq!(devtools_port(":2"), 9223);
assert_eq!(devtools_port("3"), 9224);
}
#[test]
fn command_passes_port_profile_and_script() {
let argv = cdp_command_on(
":2",
Some("/home/lazyboy/.browser-profiles/bots/a"),
&json!({"action": "snapshot", "ensure": true}),
);
assert!(argv.contains(&"DISPLAY=:2".into()));
assert!(argv.iter().any(|item| item.contains("python3")));
let payload = argv.last().unwrap();
assert!(payload.contains("\"port\":9223"));
assert!(payload.contains("bots/a"));
assert!(payload.contains("snapshot"));
}
#[test]
fn parses_snapshot_elements() {
let page = parse_cdp_page(
r#"{"ok":true,"url":"https://example.com","title":"Example","text":"Hello","elements":[{"id":1,"title":"Submit","selector":"[data-lazyboy=\"1\"]","kind":"dom","x":10,"y":20,"w":80,"h":24}]}"#,
);
assert!(page.ok);
assert_eq!(page.url, "https://example.com");
assert_eq!(page.elements.len(), 1);
assert_eq!(page.elements[0].title, "Submit");
assert_eq!(
page.elements[0].selector.as_deref(),
Some("[data-lazyboy=\"1\"]")
);
assert_eq!(page.elements[0].center(), (50, 32));
}
#[test]
fn merge_keeps_page_controls_and_native_dialogs() {
let windows = vec![
UiElement {
id: 1,
title: "Chromium".into(),
x: 0,
y: 0,
w: 1280,
h: 800,
kind: Some("window".into()),
..UiElement::default()
},
UiElement {
id: 2,
title: "Open File".into(),
x: 100,
y: 100,
w: 400,
h: 300,
kind: Some("window".into()),
..UiElement::default()
},
];
let page = vec![UiElement {
id: 1,
title: "Login".into(),
selector: Some("[data-lazyboy=\"1\"]".into()),
kind: Some("dom".into()),
x: 40,
y: 80,
w: 60,
h: 20,
..UiElement::default()
}];
let merged = merge_page_elements(windows, &page);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].title, "Login");
assert_eq!(merged[1].id, 2);
assert_eq!(merged[1].title, "Open File");
}
#[test]
fn unavailable_page_is_not_ok() {
let page = parse_cdp_page(r#"{"ok":false,"error":"cdp unavailable"}"#);
assert!(!page.ok);
assert_eq!(page.error.as_deref(), Some("cdp unavailable"));
}
}

View File

@ -39,7 +39,9 @@ pub fn can_take_screen_lease(existing: Option<&str>, incoming: Option<&str>) ->
match existing { match existing {
None => true, None => true,
Some(existing) if existing == incoming => true, Some(existing) if existing == incoming => true,
Some(existing) => parse_screen_lease_id(incoming).fence > parse_screen_lease_id(existing).fence, Some(existing) => {
parse_screen_lease_id(incoming).fence > parse_screen_lease_id(existing).fence
}
} }
} }

View File

@ -1,6 +1,8 @@
mod actions; mod actions;
mod cdp;
mod lease; mod lease;
mod observe; mod observe;
mod overlay;
mod path; mod path;
mod sandbox; mod sandbox;
mod screen; mod screen;
@ -8,8 +10,10 @@ mod takeover;
mod x11; mod x11;
pub use actions::*; pub use actions::*;
pub use cdp::*;
pub use lease::*; pub use lease::*;
pub use observe::*; pub use observe::*;
pub use overlay::*;
pub use path::*; pub use path::*;
pub use sandbox::*; pub use sandbox::*;
pub use screen::*; pub use screen::*;

View File

@ -1,5 +1,5 @@
use chrono::Utc; use chrono::Utc;
use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition}; use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition, UiElement};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub fn observation_from_png( pub fn observation_from_png(
@ -12,12 +12,40 @@ pub fn observation_from_png(
ComputerObservation { ComputerObservation {
frame_id: hex::encode(Sha256::digest(&image)), frame_id: hex::encode(Sha256::digest(&image)),
captured_at: Utc::now().to_rfc3339(), captured_at: Utc::now().to_rfc3339(),
mime_type: "image/png".to_string(), mime_type: sniff_image_mime(&image).to_string(),
image, image,
width, width,
height, height,
cursor, cursor,
active_window, active_window,
elements: Vec::new(),
}
}
pub fn observation_with_elements(
mut observation: ComputerObservation,
elements: Vec<UiElement>,
) -> ComputerObservation {
observation.elements = elements;
observation
}
pub fn format_ui_elements(elements: &[UiElement]) -> String {
if elements.is_empty() {
return "none".into();
}
elements
.iter()
.map(|element| format!("[{}] {}", element.id, element.title))
.collect::<Vec<_>>()
.join(" ")
}
fn sniff_image_mime(image: &[u8]) -> &'static str {
if image.len() >= 3 && image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF {
"image/jpeg"
} else {
"image/png"
} }
} }
@ -38,4 +66,25 @@ mod tests {
let c = observation_from_png(vec![1, 2, 4], 1, 1, None, None); let c = observation_from_png(vec![1, 2, 4], 1, 1, None, None);
assert!(!frames_match(Some(&a.frame_id), &c)); assert!(!frames_match(Some(&a.frame_id), &c));
} }
#[test]
fn jpeg_magic_sets_mime() {
let observation = observation_from_png(vec![0xFF, 0xD8, 0xFF, 0x00], 1, 1, None, None);
assert_eq!(observation.mime_type, "image/jpeg");
}
#[test]
fn formats_numbered_windows() {
let elements = vec![UiElement {
id: 1,
title: "Chromium".into(),
x: 0,
y: 0,
w: 100,
h: 40,
..UiElement::default()
}];
assert_eq!(format_ui_elements(&elements), "[1] Chromium");
assert_eq!(format_ui_elements(&[]), "none");
}
} }

View File

@ -0,0 +1,267 @@
use std::io::Cursor;
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, Rgb, RgbImage};
use lazyboy_contracts::UiElement;
const YELLOW: Rgb<u8> = Rgb([255, 214, 0]);
const BLACK: Rgb<u8> = Rgb([16, 16, 16]);
const SCALE: u32 = 6;
/// 5x7 bitmap digits, MSB is the left pixel of each row.
const FONT: [[u8; 7]; 10] = [
[
0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
],
[
0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110,
],
[
0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111,
],
[
0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110,
],
[
0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010,
],
[
0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110,
],
[
0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110,
],
[
0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000,
],
[
0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110,
],
[
0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100,
],
];
/// Draw numbered boxes on a screenshot for the model. The live VNC feed is unchanged.
/// Empty element lists and undecodable bytes are returned as-is.
pub fn overlay_elements(bytes: &[u8], elements: &[UiElement]) -> Vec<u8> {
if elements.is_empty() {
return bytes.to_vec();
}
let Ok(dynamic) = image::load_from_memory(bytes) else {
return bytes.to_vec();
};
let mut img = dynamic.to_rgb8();
let (width, height) = img.dimensions();
if width == 0 || height == 0 {
return bytes.to_vec();
}
for element in elements.iter().take(50) {
paint_element(&mut img, element, width, height);
}
encode_jpeg(&img).unwrap_or_else(|| bytes.to_vec())
}
fn paint_element(img: &mut RgbImage, element: &UiElement, width: u32, height: u32) {
let x1 = element.x.min(width.saturating_sub(1));
let y1 = element.y.min(height.saturating_sub(1));
let x2 = element
.x
.saturating_add(element.w.max(1))
.saturating_sub(1)
.min(width.saturating_sub(1));
let y2 = element
.y
.saturating_add(element.h.max(1))
.saturating_sub(1)
.min(height.saturating_sub(1));
if x2 <= x1 || y2 <= y1 {
draw_badge(img, x1, y1, element.id);
return;
}
let area = u64::from(element.w.saturating_mul(element.h.max(1)));
let full = u64::from(width) * u64::from(height);
if full == 0 || area.saturating_mul(10) < full.saturating_mul(7) {
stroke_rect(img, x1, y1, x2, y2, BLACK, 6);
stroke_rect(img, x1, y1, x2, y2, YELLOW, 3);
}
draw_badge(img, x1, y1, element.id);
}
fn stroke_rect(
img: &mut RgbImage,
x1: u32,
y1: u32,
x2: u32,
y2: u32,
color: Rgb<u8>,
thickness: u32,
) {
for t in 0..thickness {
let left = x1.saturating_add(t).min(x2);
let right = x2.saturating_sub(t).max(x1);
let top = y1.saturating_add(t).min(y2);
let bottom = y2.saturating_sub(t).max(y1);
for x in left..=right {
put(img, x, top, color);
put(img, x, bottom, color);
}
for y in top..=bottom {
put(img, left, y, color);
put(img, right, y, color);
}
}
}
fn draw_badge(img: &mut RgbImage, x: u32, y: u32, id: u32) {
let label = id.to_string();
let digits = label.len() as u32;
let pad = 4;
let digit_w = 5 * SCALE + 2;
let digit_h = 7 * SCALE;
let bw = pad * 2 + digits * digit_w - 2;
let bh = pad * 2 + digit_h;
let (width, height) = img.dimensions();
let bx = x.min(width.saturating_sub(bw.max(1)));
let by = if y >= bh + 2 {
y.saturating_sub(bh + 2)
} else {
y.min(height.saturating_sub(bh.max(1)))
};
fill_rect(
img,
bx,
by,
bx.saturating_add(bw.saturating_sub(1)),
by.saturating_add(bh.saturating_sub(1)),
YELLOW,
);
stroke_rect(
img,
bx,
by,
bx.saturating_add(bw.saturating_sub(1)),
by.saturating_add(bh.saturating_sub(1)),
BLACK,
2,
);
for (i, ch) in label.bytes().enumerate() {
if !(b'0'..=b'9').contains(&ch) {
continue;
}
let dx = bx + pad + i as u32 * digit_w;
let dy = by + pad;
draw_digit(img, dx, dy, (ch - b'0') as usize);
}
}
fn fill_rect(img: &mut RgbImage, x1: u32, y1: u32, x2: u32, y2: u32, color: Rgb<u8>) {
for y in y1..=y2 {
for x in x1..=x2 {
put(img, x, y, color);
}
}
}
fn draw_digit(img: &mut RgbImage, x: u32, y: u32, digit: usize) {
let Some(rows) = FONT.get(digit) else {
return;
};
for (row, bits) in rows.iter().enumerate() {
for col in 0..5u32 {
if bits & (1 << (4 - col)) == 0 {
continue;
}
for oy in 0..SCALE {
for ox in 0..SCALE {
put(
img,
x.saturating_add(col * SCALE + ox),
y.saturating_add(row as u32 * SCALE + oy),
BLACK,
);
}
}
}
}
}
fn put(img: &mut RgbImage, x: u32, y: u32, color: Rgb<u8>) {
if x < img.width() && y < img.height() {
img.put_pixel(x, y, color);
}
}
fn encode_jpeg(img: &RgbImage) -> Option<Vec<u8>> {
let mut out = Cursor::new(Vec::new());
let mut encoder = JpegEncoder::new_with_quality(&mut out, 60);
encoder
.encode(
img.as_raw(),
img.width(),
img.height(),
ExtendedColorType::Rgb8,
)
.ok()?;
Some(out.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgb;
fn jpeg_of(width: u32, height: u32, color: Rgb<u8>) -> Vec<u8> {
let img = RgbImage::from_pixel(width, height, color);
encode_jpeg(&img).expect("jpeg")
}
#[test]
fn empty_elements_keeps_bytes() {
let bytes = vec![1, 2, 3, 4];
assert_eq!(overlay_elements(&bytes, &[]), bytes);
}
#[test]
fn paints_a_yellow_badge() {
let src = jpeg_of(160, 100, Rgb([8, 12, 40]));
let out = overlay_elements(
&src,
&[UiElement {
id: 7,
title: "Submit".into(),
x: 20,
y: 30,
w: 50,
h: 24,
..UiElement::default()
}],
);
assert!(out.starts_with(&[0xFF, 0xD8, 0xFF]));
let painted = image::load_from_memory(&out).unwrap().to_rgb8();
assert!(
painted
.pixels()
.any(|pixel| pixel[0] > 200 && pixel[1] > 180 && pixel[2] < 40),
"expected a yellow mark on the overlay"
);
}
#[test]
fn junk_bytes_pass_through() {
let junk = b"not-an-image";
let out = overlay_elements(
junk,
&[UiElement {
id: 1,
title: "x".into(),
x: 0,
y: 0,
w: 10,
h: 10,
..UiElement::default()
}],
);
assert_eq!(out, junk);
}
}

View File

@ -9,8 +9,14 @@ pub enum PathError {
pub fn normalize_workspace_path(value: &str) -> Result<String, PathError> { pub fn normalize_workspace_path(value: &str) -> Result<String, PathError> {
let normalized = value.replace('\\', "/").trim_start_matches('/').to_string(); let normalized = value.replace('\\', "/").trim_start_matches('/').to_string();
let segments: Vec<&str> = normalized.split('/').filter(|segment| !segment.is_empty()).collect(); let segments: Vec<&str> = normalized
if segments.iter().any(|segment| *segment == "." || *segment == "..") { .split('/')
.filter(|segment| !segment.is_empty())
.collect();
if segments
.iter()
.any(|segment| *segment == "." || *segment == "..")
{
return Err(PathError::EscapesWorkspace); return Err(PathError::EscapesWorkspace);
} }
Ok(segments.join("/")) Ok(segments.join("/"))
@ -124,7 +130,8 @@ mod tests {
#[test] #[test]
fn dedicated_paths_stay_at_home_root() { fn dedicated_paths_stay_at_home_root() {
let path = resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-1", "notes/a.txt").unwrap(); let path =
resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-1", "notes/a.txt").unwrap();
assert_eq!(path, "notes/a.txt"); assert_eq!(path, "notes/a.txt");
} }
@ -172,7 +179,8 @@ mod tests {
#[test] #[test]
fn private_homes_are_not_the_team_home() { fn private_homes_are_not_the_team_home() {
let team = lazyboy_contracts::computer_home_key(ComputerMode::Team, "space-1", None).unwrap(); let team =
lazyboy_contracts::computer_home_key(ComputerMode::Team, "space-1", None).unwrap();
let private = lazyboy_contracts::computer_home_key( let private = lazyboy_contracts::computer_home_key(
ComputerMode::Dedicated, ComputerMode::Dedicated,
"space-1", "space-1",
@ -181,7 +189,8 @@ mod tests {
.unwrap(); .unwrap();
assert_ne!(team, private); assert_ne!(team, private);
assert_eq!( assert_eq!(
resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-private", "shared/secret.txt").unwrap(), resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-private", "shared/secret.txt")
.unwrap(),
"shared/secret.txt" "shared/secret.txt"
); );
} }

View File

@ -147,7 +147,11 @@ pub trait SandboxProvider: Send + Sync {
context: &AdapterContext, context: &AdapterContext,
) -> Result<ComputerRef, SandboxError>; ) -> Result<ComputerRef, SandboxError>;
async fn prepare(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn prepare(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
let _ = (computer, context); let _ = (computer, context);
Ok(()) Ok(())
} }
@ -185,7 +189,11 @@ pub trait SandboxProvider: Send + Sync {
self.provision(request, context).await self.provision(request, context).await
} }
async fn suspend(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn suspend(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.stop(computer, context).await self.stop(computer, context).await
} }
@ -246,7 +254,15 @@ pub trait SandboxProvider: Send + Sync {
context: &AdapterContext, context: &AdapterContext,
) -> Result<(), SandboxError>; ) -> Result<(), SandboxError>;
async fn stop(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError>; async fn stop(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError>;
async fn destroy(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError>; async fn destroy(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError>;
} }

View File

@ -1,8 +1,8 @@
pub use lazyboy_contracts::TEAM_SCREEN_LIMIT;
use lazyboy_contracts::{ use lazyboy_contracts::{
BrowserProfileMode, ComputerCapabilities, MULTI_SCREEN_UNAVAILABLE, PROFILE_LOCKED, BrowserProfileMode, ComputerCapabilities, MULTI_SCREEN_UNAVAILABLE, PROFILE_LOCKED,
TEAM_SCREENS_FULL, TEAM_SCREENS_FULL,
}; };
pub use lazyboy_contracts::TEAM_SCREEN_LIMIT;
use thiserror::Error; use thiserror::Error;
use crate::HOME; use crate::HOME;
@ -66,7 +66,11 @@ pub fn allocate_slot(used: &[u32]) -> Option<u32> {
(0..TEAM_SCREEN_LIMIT).find(|slot| !used.contains(slot)) (0..TEAM_SCREEN_LIMIT).find(|slot| !used.contains(slot))
} }
pub fn browser_profile_path(mode: BrowserProfileMode, bot_id: &str, run_id: Option<&str>) -> String { pub fn browser_profile_path(
mode: BrowserProfileMode,
bot_id: &str,
run_id: Option<&str>,
) -> String {
match mode { match mode {
BrowserProfileMode::Shared => format!("{HOME}/.browser-profiles/chromium"), BrowserProfileMode::Shared => format!("{HOME}/.browser-profiles/chromium"),
BrowserProfileMode::PerBot => format!("{HOME}/.browser-profiles/bots/{bot_id}"), BrowserProfileMode::PerBot => format!("{HOME}/.browser-profiles/bots/{bot_id}"),
@ -82,7 +86,10 @@ pub fn profile_lock_key(mode: BrowserProfileMode, bot_id: &str, run_id: Option<&
BrowserProfileMode::Shared => "shared".into(), BrowserProfileMode::Shared => "shared".into(),
BrowserProfileMode::PerBot => format!("bot:{bot_id}"), BrowserProfileMode::PerBot => format!("bot:{bot_id}"),
BrowserProfileMode::PerTask => { BrowserProfileMode::PerTask => {
format!("task:{}", run_id.filter(|value| !value.is_empty()).unwrap_or("task")) format!(
"task:{}",
run_id.filter(|value| !value.is_empty()).unwrap_or("task")
)
} }
} }
} }
@ -132,7 +139,9 @@ pub fn admit_new_screen(
return Ok(slot); return Ok(slot);
} }
if !capabilities.multi_screen && !used_slots.is_empty() { if !capabilities.multi_screen && !used_slots.is_empty() {
return Err(GuiBlock::MultiScreenUnavailable { busy_bot_name: None }); return Err(GuiBlock::MultiScreenUnavailable {
busy_bot_name: None,
});
} }
allocate_slot(used_slots).ok_or(GuiBlock::ScreensFull) allocate_slot(used_slots).ok_or(GuiBlock::ScreensFull)
} }
@ -163,10 +172,16 @@ impl Default for ScreenTarget {
} }
impl ScreenTarget { impl ScreenTarget {
pub fn from_parts(display: Option<&str>, profile_path: Option<&str>, slot: Option<u32>) -> Self { pub fn from_parts(
display: Option<&str>,
profile_path: Option<&str>,
slot: Option<u32>,
) -> Self {
Self { Self {
display: normalize_display(display.unwrap_or_default()).to_string(), display: normalize_display(display.unwrap_or_default()).to_string(),
profile_path: profile_path.filter(|value| !value.is_empty()).map(str::to_string), profile_path: profile_path
.filter(|value| !value.is_empty())
.map(str::to_string),
slot: slot.unwrap_or(0), slot: slot.unwrap_or(0),
} }
} }
@ -222,7 +237,9 @@ mod tests {
#[test] #[test]
fn multi_screen_false_blocks_a_second_bot_gui_only() { fn multi_screen_false_blocks_a_second_bot_gui_only() {
let caps = ComputerCapabilities { multi_screen: false }; let caps = ComputerCapabilities {
multi_screen: false,
};
let err = admit_new_screen(&caps, &[0], None).unwrap_err(); let err = admit_new_screen(&caps, &[0], None).unwrap_err();
assert!(matches!(err, GuiBlock::MultiScreenUnavailable { .. })); assert!(matches!(err, GuiBlock::MultiScreenUnavailable { .. }));
assert!(admit_new_screen(&caps, &[0], Some(0)).is_ok()); assert!(admit_new_screen(&caps, &[0], Some(0)).is_ok());

View File

@ -1,23 +1,16 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lazyboy_contracts::RunStatus; use lazyboy_contracts::RunStatus;
/// Mirrors computer.takeover: an execution lease blocks user control unless /// Humans may take the pointer during a live run. The worker pauses at the
/// the run is waiting for a human. /// next turn boundary (or aborts the in-flight model/tool call) instead of
/// competing for the mouse.
pub fn execution_blocks_user_takeover( pub fn execution_blocks_user_takeover(
has_lease: bool, _has_lease: bool,
lease_expires_at: Option<DateTime<Utc>>, _lease_expires_at: Option<DateTime<Utc>>,
run_status: Option<RunStatus>, _run_status: Option<RunStatus>,
now: DateTime<Utc>, _now: DateTime<Utc>,
) -> bool { ) -> bool {
if !has_lease { false
return false;
}
if run_status == Some(RunStatus::WaitingTakeover) {
return false;
}
let lease_active = lease_expires_at.is_some_and(|expires| expires > now);
let run_active = run_status.is_some_and(RunStatus::is_active);
lease_active || run_active
} }
pub fn user_holds_control( pub fn user_holds_control(
@ -44,8 +37,8 @@ mod tests {
} }
#[test] #[test]
fn live_run_blocks_takeover() { fn live_run_does_not_block_takeover() {
assert!(execution_blocks_user_takeover( assert!(!execution_blocks_user_takeover(
true, true,
Some(now() + Duration::minutes(4)), Some(now() + Duration::minutes(4)),
Some(RunStatus::Running), Some(RunStatus::Running),

View File

@ -1,6 +1,6 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection}; use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection};
use crate::screen::{normalize_display, PRIMARY_DISPLAY}; use crate::screen::{PRIMARY_DISPLAY, normalize_display};
pub const DISPLAY: &str = PRIMARY_DISPLAY; pub const DISPLAY: &str = PRIMARY_DISPLAY;
pub const HOME: &str = "/home/lazyboy"; pub const HOME: &str = "/home/lazyboy";
@ -82,7 +82,13 @@ pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option<Vec<Str
ComputerAction::Clipboard { text } => { ComputerAction::Clipboard { text } => {
let quoted = shell_single_quote(text); let quoted = shell_single_quote(text);
if looks_like_typed_ascii(text) { if looks_like_typed_ascii(text) {
argv.extend(["type".into(), "--delay".into(), "16".into(), "--".into(), text.clone()]); argv.extend([
"type".into(),
"--delay".into(),
"16".into(),
"--".into(),
text.clone(),
]);
} else { } else {
return Some(vec![ return Some(vec![
"env".into(), "env".into(),
@ -121,7 +127,9 @@ pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option<Vec<Str
), ),
]); ]);
} }
ComputerAction::Wait { .. } | ComputerAction::Open { .. } | ComputerAction::Launch { .. } => { ComputerAction::Wait { .. }
| ComputerAction::Open { .. }
| ComputerAction::Launch { .. } => {
return None; return None;
} }
} }
@ -134,7 +142,9 @@ fn shell_single_quote(value: &str) -> String {
fn looks_like_typed_ascii(text: &str) -> bool { fn looks_like_typed_ascii(text: &str) -> bool {
text.len() <= 48 text.len() <= 48
&& text.chars().all(|ch| ch.is_ascii() && (!ch.is_control() || ch == '\n' || ch == '\t')) && text
.chars()
.all(|ch| ch.is_ascii() && (!ch.is_control() || ch == '\n' || ch == '\t'))
} }
pub fn action_pause_ms(action: &ComputerAction) -> u64 { pub fn action_pause_ms(action: &ComputerAction) -> u64 {
@ -183,25 +193,107 @@ print(json.dumps({"x": int(vals.get("X") or 0), "y": int(vals.get("Y") or 0), "i
] ]
} }
pub fn parse_pointer_state(raw: &str) -> (Option<lazyboy_contracts::CursorPosition>, Option<lazyboy_contracts::ActiveWindow>) { pub fn window_list_command() -> Vec<String> {
let value: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null); window_list_command_on(PRIMARY_DISPLAY)
let cursor = match (value.get("x").and_then(serde_json::Value::as_i64), value.get("y").and_then(serde_json::Value::as_i64)) { }
pub fn window_list_command_on(display: &str) -> Vec<String> {
vec![
"env".into(),
display_env(display),
"python3".into(),
"-c".into(),
r#"
import json, subprocess
def out(args):
try:
return subprocess.check_output(args, stderr=subprocess.DEVNULL, text=True)
except Exception:
return ""
els = []
n = 1
for line in out(["wmctrl", "-lG"]).splitlines():
parts = line.split(None, 7)
if len(parts) < 7:
continue
try:
x, y, w, h = int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5])
except ValueError:
continue
if w < 32 or h < 16:
continue
title = parts[7].strip() if len(parts) > 7 else parts[6]
if not title or title in ("Desktop", "xfce4-panel"):
continue
els.append({"id": n, "title": title[:80], "kind": "window", "x": max(0, x), "y": max(0, y), "w": w, "h": h})
n += 1
print(json.dumps(els))
"#
.into(),
]
}
pub fn parse_ui_elements(raw: &str) -> Vec<lazyboy_contracts::UiElement> {
let value: serde_json::Value =
serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null);
let Some(items) = value.as_array() else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
Some(lazyboy_contracts::UiElement {
id: item.get("id")?.as_u64()? as u32,
title: item.get("title")?.as_str()?.to_string(),
x: item.get("x")?.as_u64()? as u32,
y: item.get("y")?.as_u64()? as u32,
w: item.get("w")?.as_u64()? as u32,
h: item.get("h")?.as_u64()? as u32,
selector: item
.get("selector")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string),
kind: item
.get("kind")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string),
})
})
.collect()
}
pub fn parse_pointer_state(
raw: &str,
) -> (
Option<lazyboy_contracts::CursorPosition>,
Option<lazyboy_contracts::ActiveWindow>,
) {
let value: serde_json::Value =
serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null);
let cursor = match (
value.get("x").and_then(serde_json::Value::as_i64),
value.get("y").and_then(serde_json::Value::as_i64),
) {
(Some(x), Some(y)) => Some(lazyboy_contracts::CursorPosition { (Some(x), Some(y)) => Some(lazyboy_contracts::CursorPosition {
x: x as i32, x: x as i32,
y: y as i32, y: y as i32,
}), }),
_ => None, _ => None,
}; };
let window = value.get("id").and_then(serde_json::Value::as_str).filter(|id| !id.is_empty()).map(|id| { let window = value
lazyboy_contracts::ActiveWindow { .get("id")
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(|id| lazyboy_contracts::ActiveWindow {
id: id.to_string(), id: id.to_string(),
title: value title: value
.get("title") .get("title")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.filter(|title| !title.is_empty()) .filter(|title| !title.is_empty())
.map(str::to_string), .map(str::to_string),
} });
});
(cursor, window) (cursor, window)
} }
@ -236,7 +328,11 @@ pub fn launch_argv_on(
Some(browser_argv(display, profile, uri)) Some(browser_argv(display, profile, uri))
} }
"xterm" | "terminal" | "xfce4-terminal" | "lazyboy-terminal" => { "xterm" | "terminal" | "xfce4-terminal" | "lazyboy-terminal" => {
let mut argv = vec!["env".into(), display_env(display), "lazyboy-terminal".into()]; let mut argv = vec![
"env".into(),
display_env(display),
"lazyboy-terminal".into(),
];
if let Some(uri) = uri { if let Some(uri) = uri {
argv.push(uri.into()); argv.push(uri.into());
} }
@ -252,6 +348,11 @@ fn browser_argv(display: &str, profile: Option<&str>, uri: Option<&str>) -> Vec<
argv.push(format!("LAZYBOY_BROWSER_PROFILE={profile}")); argv.push(format!("LAZYBOY_BROWSER_PROFILE={profile}"));
} }
argv.push("lazyboy-browser".into()); argv.push("lazyboy-browser".into());
argv.push(format!(
"--remote-debugging-port={}",
crate::devtools_port(display)
));
argv.push("--remote-allow-origins=*".into());
if let Some(uri) = uri { if let Some(uri) = uri {
argv.push(uri.into()); argv.push(uri.into());
} }
@ -267,7 +368,7 @@ pub fn screenshot_command_on(display: &str) -> Vec<String> {
"bash".into(), "bash".into(),
"-lc".into(), "-lc".into(),
format!( format!(
"DISPLAY={} xwd -root -silent | convert xwd:- png:-", "DISPLAY={} xwd -root -silent | convert xwd:- -quality 60 jpeg:-",
normalize_display(display) normalize_display(display)
), ),
] ]
@ -319,9 +420,43 @@ mod tests {
assert!(argv.contains(&"DISPLAY=:2".into())); assert!(argv.contains(&"DISPLAY=:2".into()));
let shot = screenshot_command_on(":3"); let shot = screenshot_command_on(":3");
assert!(shot.last().unwrap().contains("DISPLAY=:3")); assert!(shot.last().unwrap().contains("DISPLAY=:3"));
let browser = launch_argv_on(":2", Some("/home/lazyboy/.browser-profiles/bots/a"), "browser", None) assert!(shot.last().unwrap().contains("jpeg:-"));
.unwrap(); let browser = launch_argv_on(
":2",
Some("/home/lazyboy/.browser-profiles/bots/a"),
"browser",
None,
)
.unwrap();
assert!(browser.contains(&"DISPLAY=:2".into())); assert!(browser.contains(&"DISPLAY=:2".into()));
assert!(browser.iter().any(|item| item.contains("bots/a"))); assert!(browser.iter().any(|item| item.contains("bots/a")));
assert!(browser.contains(&"--remote-debugging-port=9223".into()));
}
#[test]
fn parses_window_list_elements() {
let elements =
parse_ui_elements(r#"[{"id":1,"title":"Chromium","x":0,"y":24,"w":1280,"h":776}]"#);
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].title, "Chromium");
assert_eq!(elements[0].center(), (640, 412));
}
#[test]
fn parses_dom_element_selector() {
let elements = parse_ui_elements(
r#"[{"id":1,"title":"Submit","selector":"[data-lazyboy=\"1\"]","kind":"dom","x":10,"y":20,"w":80,"h":24}]"#,
);
assert_eq!(elements[0].kind.as_deref(), Some("dom"));
assert_eq!(
elements[0].selector.as_deref(),
Some("[data-lazyboy=\"1\"]")
);
}
#[test]
fn empty_or_junk_window_list_is_empty() {
assert!(parse_ui_elements("").is_empty());
assert!(parse_ui_elements("not-json").is_empty());
} }
} }

View File

@ -7,8 +7,9 @@ use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::ComputerAction; use lazyboy_contracts::ComputerAction;
use lazyboy_control::{ use lazyboy_control::{
action_pause_ms, launch_argv_on, normalize_display, open_argv_on, parse_pointer_state, ActionRequest, PRIMARY_DISPLAY, action_pause_ms, launch_argv_on, normalize_display,
pointer_state_command_on, screenshot_command_on, xdotool_argv_on, ActionRequest, PRIMARY_DISPLAY, open_argv_on, parse_pointer_state, parse_ui_elements, pointer_state_command_on,
screenshot_command_on, window_list_command_on, xdotool_argv_on,
}; };
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::process::Command; use tokio::process::Command;
@ -48,7 +49,10 @@ fn authorized(headers: &HeaderMap, token: &str) -> bool {
} }
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
headers.get(name).and_then(|value| value.to_str().ok()).filter(|value| !value.is_empty()) headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
} }
fn display_of(headers: &HeaderMap, fallback: Option<&str>) -> String { fn display_of(headers: &HeaderMap, fallback: Option<&str>) -> String {
@ -67,7 +71,10 @@ fn profile_of(headers: &HeaderMap, fallback: Option<&str>) -> Option<String> {
.map(str::to_string) .map(str::to_string)
} }
async fn observe(State(app): State<App>, headers: HeaderMap) -> Result<Json<serde_json::Value>, StatusCode> { async fn observe(
State(app): State<App>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, StatusCode> {
if !authorized(&headers, &app.token) { if !authorized(&headers, &app.token) {
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
@ -114,15 +121,22 @@ async fn act(
Ok(Json(body)) Ok(Json(body))
} }
async fn apply_action(display: &str, profile: Option<&str>, action: &ComputerAction) -> Result<(), String> { async fn apply_action(
display: &str,
profile: Option<&str>,
action: &ComputerAction,
) -> Result<(), String> {
match action { match action {
ComputerAction::Wait { ms } => { ComputerAction::Wait { ms } => {
sleep(Duration::from_millis(*ms as u64)).await; sleep(Duration::from_millis(*ms as u64)).await;
Ok(()) Ok(())
} }
ComputerAction::Open { path } => spawn_detached(&open_argv_on(display, profile, path)).await, ComputerAction::Open { path } => {
spawn_detached(&open_argv_on(display, profile, path)).await
}
ComputerAction::Focus { .. } => { ComputerAction::Focus { .. } => {
let argv = xdotool_argv_on(display, action).ok_or_else(|| "unsupported action".to_string())?; let argv =
xdotool_argv_on(display, action).ok_or_else(|| "unsupported action".to_string())?;
let output = Command::new(&argv[0]) let output = Command::new(&argv[0])
.args(&argv[1..]) .args(&argv[1..])
.output() .output()
@ -140,7 +154,8 @@ async fn apply_action(display: &str, profile: Option<&str>, action: &ComputerAct
spawn_detached(&argv).await spawn_detached(&argv).await
} }
other => { other => {
let argv = xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?; let argv =
xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?;
let output = Command::new(&argv[0]) let output = Command::new(&argv[0])
.args(&argv[1..]) .args(&argv[1..])
.output() .output()
@ -178,9 +193,22 @@ async fn observation_json(display: &str, png: Vec<u8>) -> serde_json::Value {
if let Some(window) = window { if let Some(window) = window {
body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title }); body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title });
} }
let elements = run_window_list(display).await;
if !elements.is_empty() {
body["elements"] = serde_json::to_value(elements).unwrap_or(serde_json::json!([]));
}
body body
} }
async fn run_window_list(display: &str) -> Vec<lazyboy_contracts::UiElement> {
let argv = window_list_command_on(display);
let output = Command::new(&argv[0]).args(&argv[1..]).output().await.ok();
let Some(output) = output else {
return Vec::new();
};
parse_ui_elements(&String::from_utf8_lossy(&output.stdout))
}
async fn run_pointer_state( async fn run_pointer_state(
display: &str, display: &str,
) -> ( ) -> (

View File

@ -1,6 +1,6 @@
use lazyboy_contracts::{ use lazyboy_contracts::{ModelCapabilities, ModelProvider, default_model_id, model_capabilities};
default_model_id, model_capabilities, ModelCapabilities, ModelProvider, DEFAULT_XAI_MODEL, use rig_core::client::CompletionClient;
}; use rig_core::providers::{openai, xai};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)] #[derive(Debug, Error, Clone, PartialEq, Eq)]
@ -8,10 +8,9 @@ pub enum ModelError {
#[error("unsupported_provider:{provider}")] #[error("unsupported_provider:{provider}")]
UnsupportedProvider { provider: String }, UnsupportedProvider { provider: String },
#[error("missing credential for {provider} ({env_key})")] #[error("missing credential for {provider} ({env_key})")]
MissingCredential { MissingCredential { provider: String, env_key: String },
provider: String, #[error("missing base URL for {provider}")]
env_key: String, MissingBaseUrl { provider: String },
},
#[error("unknown model provider: {0}")] #[error("unknown model provider: {0}")]
UnknownProvider(String), UnknownProvider(String),
#[error("model client error: {0}")] #[error("model client error: {0}")]
@ -39,6 +38,7 @@ impl CredentialChain {
pub struct ResolveModelRequest { pub struct ResolveModelRequest {
pub provider: ModelProvider, pub provider: ModelProvider,
pub model_id: Option<String>, pub model_id: Option<String>,
pub base_url: Option<String>,
pub credentials: CredentialChain, pub credentials: CredentialChain,
} }
@ -52,12 +52,9 @@ pub struct ResolvedBackend {
} }
/// Pick a backend without talking to the network. /// Pick a backend without talking to the network.
///
/// v1 only constructs an xAI client. Other providers stay in the enum so a later
/// mapping can land without changing Computer tools.
pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend, ModelError> { pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend, ModelError> {
match request.provider { match request.provider {
ModelProvider::Xai => {} ModelProvider::Xai | ModelProvider::OpencodeGo | ModelProvider::OpenaiCompatible => {}
other => { other => {
return Err(ModelError::UnsupportedProvider { return Err(ModelError::UnsupportedProvider {
provider: other.as_str().to_string(), provider: other.as_str().to_string(),
@ -65,56 +62,114 @@ pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend,
} }
} }
let api_key = request.credentials.resolve().ok_or(ModelError::MissingCredential { let api_key = match request.credentials.resolve() {
provider: request.provider.as_str().to_string(), Some(key) => key.to_string(),
env_key: request.provider.env_key_name().to_string(), None if request.provider.requires_api_key() => {
})?; return Err(ModelError::MissingCredential {
provider: request.provider.as_str().to_string(),
env_key: request.provider.env_key_name().to_string(),
});
}
None => String::new(),
};
let model_id = request let model_id = request
.model_id .model_id
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or_else(|| default_model_id(request.provider).map(str::to_string)) .or_else(|| default_model_id(request.provider).map(str::to_string))
.unwrap_or_else(|| DEFAULT_XAI_MODEL.to_string()); .ok_or_else(|| ModelError::ProviderClient("missing model id".into()))?;
let base_url = request let base_url = request
.provider .base_url
.default_base_url() .filter(|value| !value.trim().is_empty())
.unwrap_or("https://api.x.ai/v1") .or_else(|| request.provider.default_base_url().map(str::to_string))
.to_string(); .ok_or_else(|| ModelError::MissingBaseUrl {
provider: request.provider.as_str().to_string(),
})?;
let base_url = rewrite_loopback_host(&normalize_base_url(&base_url));
Ok(ResolvedBackend { Ok(ResolvedBackend {
provider: request.provider, provider: request.provider,
model_id: model_id.clone(), model_id: model_id.clone(),
capabilities: model_capabilities(request.provider, &model_id), capabilities: model_capabilities(request.provider, &model_id),
api_key: api_key.to_string(), api_key,
base_url, base_url,
}) })
} }
fn normalize_base_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
fn rewrite_loopback_host(url: &str) -> String {
let supervisor = std::env::var("SANDBOX_SUPERVISOR_URL").unwrap_or_default();
if !supervisor.contains("://supervisor") {
return url.to_string();
}
url.replace("://127.0.0.1", "://host.docker.internal")
.replace("://localhost", "://host.docker.internal")
}
pub enum DynModel {
Xai(xai::completion::CompletionModel),
OpenAi(openai::completion::CompletionModel),
}
pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
match backend.provider {
ModelProvider::Xai => {
let client = xai::Client::new(&backend.api_key)
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::Xai(client.completion_model(&backend.model_id)))
}
ModelProvider::OpencodeGo | ModelProvider::OpenaiCompatible => {
let key = if backend.api_key.is_empty() {
"local"
} else {
backend.api_key.as_str()
};
let client = openai::CompletionsClient::builder()
.api_key(key.to_string())
.base_url(&backend.base_url)
.build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
}
other => Err(ModelError::UnsupportedProvider {
provider: other.as_str().to_string(),
}),
}
}
pub fn credential_from_env(provider: ModelProvider) -> Option<String> { pub fn credential_from_env(provider: ModelProvider) -> Option<String> {
std::env::var(provider.env_key_name()).ok().filter(|value| !value.is_empty()) std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty())
} }
/// Prove the xAI Rig client can be constructed from a resolved backend. /// Prove the xAI Rig client can be constructed from a resolved backend.
pub fn connect_xai(backend: &ResolvedBackend) -> Result<rig_core::providers::xai::Client, ModelError> { pub fn connect_xai(
backend: &ResolvedBackend,
) -> Result<rig_core::providers::xai::Client, ModelError> {
if backend.provider != ModelProvider::Xai { if backend.provider != ModelProvider::Xai {
return Err(ModelError::UnsupportedProvider { return Err(ModelError::UnsupportedProvider {
provider: backend.provider.as_str().to_string(), provider: backend.provider.as_str().to_string(),
}); });
} }
rig_core::providers::xai::Client::new(&backend.api_key).map_err(|error| { rig_core::providers::xai::Client::new(&backend.api_key)
ModelError::ProviderClient(error.to_string()) .map_err(|error| ModelError::ProviderClient(error.to_string()))
})
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use lazyboy_contracts::DEFAULT_XAI_MODEL;
fn xai_request(key: Option<&str>) -> ResolveModelRequest { fn xai_request(key: Option<&str>) -> ResolveModelRequest {
ResolveModelRequest { ResolveModelRequest {
provider: ModelProvider::Xai, provider: ModelProvider::Xai,
model_id: None, model_id: None,
base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
@ -138,6 +193,7 @@ mod tests {
let backend = resolve_backend(ResolveModelRequest { let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::Xai, provider: ModelProvider::Xai,
model_id: Some("grok-4.6".into()), model_id: Some("grok-4.6".into()),
base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: Some("bot-key".into()), bot: Some("bot-key".into()),
space: Some("space-key".into()), space: Some("space-key".into()),
@ -164,6 +220,7 @@ mod tests {
let error = resolve_backend(ResolveModelRequest { let error = resolve_backend(ResolveModelRequest {
provider, provider,
model_id: Some("whatever".into()), model_id: Some("whatever".into()),
base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
@ -185,4 +242,56 @@ mod tests {
let error = "gemini".parse::<ModelProvider>().unwrap_err(); let error = "gemini".parse::<ModelProvider>().unwrap_err();
assert_eq!(error.0, "gemini"); assert_eq!(error.0, "gemini");
} }
#[test]
fn opencode_go_uses_zen_go_endpoint() {
let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::OpencodeGo,
model_id: None,
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: Some("go-key".into()),
},
})
.unwrap();
assert_eq!(backend.model_id, "glm-5.1");
assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1");
assert!(connect_model(&backend).is_ok());
}
#[test]
fn openai_compatible_allows_empty_key_and_custom_url() {
let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::OpenaiCompatible,
model_id: Some("qwen2.5".into()),
base_url: Some("http://127.0.0.1:8000/v1/".into()),
credentials: CredentialChain {
bot: None,
space: None,
env: None,
},
})
.unwrap();
assert_eq!(backend.api_key, "");
assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1");
assert!(connect_model(&backend).is_ok());
}
#[test]
fn openai_compatible_requires_base_url() {
let error = resolve_backend(ResolveModelRequest {
provider: ModelProvider::OpenaiCompatible,
model_id: Some("local".into()),
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: None,
},
})
.unwrap_err();
assert!(matches!(error, ModelError::MissingBaseUrl { .. }));
}
} }

View File

@ -1,11 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine; use base64::Engine;
use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition, SandboxKind};
use lazyboy_contracts::ComputerCapabilities; use lazyboy_contracts::ComputerCapabilities;
use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition, SandboxKind};
use lazyboy_control::{ use lazyboy_control::{
observation_from_png, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ComputerRef,
ComputerRef, EnsureScreenRequest, EnsureScreenResult, FileEntry, ProvisionRequest, SandboxError, EnsureScreenRequest, EnsureScreenResult, FileEntry, ProvisionRequest, SandboxError,
SandboxProvider, ScreenSession, SandboxProvider, ScreenSession, observation_from_png, observation_with_elements,
parse_ui_elements,
}; };
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
@ -80,7 +81,9 @@ impl SandboxProvider for DockerSandbox {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!("provision failed: {status} {body}"))); return Err(SandboxError::message(format!(
"provision failed: {status} {body}"
)));
} }
let body: Value = response let body: Value = response
.json() .json()
@ -125,14 +128,19 @@ impl SandboxProvider for DockerSandbox {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!("ensure screen failed: {status} {body}"))); return Err(SandboxError::message(format!(
"ensure screen failed: {status} {body}"
)));
} }
let body: Value = response let body: Value = response
.json() .json()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
Ok(EnsureScreenResult { Ok(EnsureScreenResult {
slot: body.get("slot").and_then(Value::as_u64).unwrap_or(request.slot as u64) as u32, slot: body
.get("slot")
.and_then(Value::as_u64)
.unwrap_or(request.slot as u64) as u32,
display: body display: body
.get("display") .get("display")
.and_then(Value::as_str) .and_then(Value::as_str)
@ -160,6 +168,13 @@ impl SandboxProvider for DockerSandbox {
.send() .send()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!(
"exec failed: {status} {body}"
)));
}
response response
.json() .json()
.await .await
@ -330,7 +345,11 @@ impl SandboxProvider for DockerSandbox {
} }
} }
async fn stop(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn stop(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.client self.client
.post(self.url(&format!("/computers/{}/stop", computer.id))) .post(self.url(&format!("/computers/{}/stop", computer.id)))
.headers(self.headers(context)) .headers(self.headers(context))
@ -340,7 +359,11 @@ impl SandboxProvider for DockerSandbox {
Ok(()) Ok(())
} }
async fn destroy(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn destroy(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.client self.client
.delete(self.url(&format!("/computers/{}", computer.id))) .delete(self.url(&format!("/computers/{}", computer.id)))
.headers(self.headers(context)) .headers(self.headers(context))
@ -382,5 +405,12 @@ fn decode_observation(body: &Value) -> Result<ComputerObservation, SandboxError>
.map(str::to_string), .map(str::to_string),
}) })
}); });
Ok(observation_from_png(png, 1280, 800, cursor, window)) let elements = body
.get("elements")
.map(|value| parse_ui_elements(&value.to_string()))
.unwrap_or_default();
Ok(observation_with_elements(
observation_from_png(png, 1280, 800, cursor, window),
elements,
))
} }

View File

@ -4,8 +4,9 @@ use std::sync::Mutex;
use async_trait::async_trait; use async_trait::async_trait;
use lazyboy_contracts::{ComputerObservation, SandboxKind}; use lazyboy_contracts::{ComputerObservation, SandboxKind};
use lazyboy_control::{ use lazyboy_control::{
observation_from_png, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ComputerRef,
ComputerRef, FileEntry, ProvisionRequest, SandboxError, SandboxProvider, ScreenSession, FileEntry, ProvisionRequest, SandboxError, SandboxProvider, ScreenSession,
observation_from_png,
}; };
const EMPTY_PNG: &[u8] = &[ const EMPTY_PNG: &[u8] = &[
@ -165,11 +166,19 @@ impl SandboxProvider for FakeSandbox {
Ok(()) Ok(())
} }
async fn stop(&self, _computer: &ComputerRef, _context: &AdapterContext) -> Result<(), SandboxError> { async fn stop(
&self,
_computer: &ComputerRef,
_context: &AdapterContext,
) -> Result<(), SandboxError> {
Ok(()) Ok(())
} }
async fn destroy(&self, computer: &ComputerRef, _context: &AdapterContext) -> Result<(), SandboxError> { async fn destroy(
&self,
computer: &ComputerRef,
_context: &AdapterContext,
) -> Result<(), SandboxError> {
self.files.lock().unwrap().remove(&computer.home_key); self.files.lock().unwrap().remove(&computer.home_key);
Ok(()) Ok(())
} }

View File

@ -3,22 +3,22 @@ use std::default::Default;
use std::path::PathBuf; use std::path::PathBuf;
use base64::Engine; use base64::Engine;
use bollard::Docker;
use bollard::container::{ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, StartContainerOptions, Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StopContainerOptions, StartContainerOptions, StopContainerOptions,
}; };
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults}; use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use bollard::models::{HostConfig, PortBinding}; use bollard::models::{HostConfig, PortBinding};
use bollard::network::CreateNetworkOptions; use bollard::network::CreateNetworkOptions;
use bollard::Docker;
use futures_util::StreamExt; use futures_util::StreamExt;
use lazyboy_control::{ use lazyboy_control::{
action_pause_ms, launch_argv_on, normalize_display, normalize_workspace_path, open_argv_on, ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME,
pointer_state_command_on, screen_layout, screenshot_command_on, xdotool_argv_on, ActionRequest, ScreenTarget, TEAM_SCREEN_LIMIT, action_pause_ms, launch_argv_on, normalize_display,
CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, ScreenTarget, HOME, normalize_workspace_path, open_argv_on, pointer_state_command_on, screen_layout,
TEAM_SCREEN_LIMIT, screenshot_command_on, window_list_command_on, xdotool_argv_on,
}; };
use tokio::time::{sleep, Duration}; use tokio::time::{Duration, sleep};
const SCREEN_PORT_COUNT: u16 = TEAM_SCREEN_LIMIT as u16; const SCREEN_PORT_COUNT: u16 = TEAM_SCREEN_LIMIT as u16;
@ -83,6 +83,7 @@ impl DockerHost {
.await .await
.ok(); .ok();
self.wait_running(&existing).await?; self.wait_running(&existing).await?;
self.wait_ready(&existing).await?;
let screen_url = self.screen_url(&existing, false).await.ok(); let screen_url = self.screen_url(&existing, false).await.ok();
return Ok(Provisioned { return Ok(Provisioned {
id: existing, id: existing,
@ -108,7 +109,7 @@ impl DockerHost {
port.clone(), port.clone(),
Some(vec![PortBinding { Some(vec![PortBinding {
host_ip: Some("127.0.0.1".into()), host_ip: Some("127.0.0.1".into()),
host_port: None, host_port: Some("0".into()),
}]), }]),
); );
exposed.insert(port, HashMap::new()); exposed.insert(port, HashMap::new());
@ -185,6 +186,11 @@ impl DockerHost {
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
self.wait_running(&created.id).await?; self.wait_running(&created.id).await?;
// The container is reported as running before its primary desktop has
// finished booting. Returning sooner lets an API ensure-screen call
// win the screen lock, which makes the container's own startup time
// out and exit. Wait for the entrypoint's readiness marker first.
self.wait_ready(&created.id).await?;
let screen_url = self.screen_url(&created.id, false).await.ok(); let screen_url = self.screen_url(&created.id, false).await.ok();
Ok(Provisioned { Ok(Provisioned {
id: created.id, id: created.id,
@ -211,7 +217,8 @@ impl DockerHost {
} else { } else {
request.argv request.argv
}; };
self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default()).await self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default())
.await
} }
pub async fn exec_on( pub async fn exec_on(
@ -241,41 +248,86 @@ impl DockerHost {
} }
pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> { pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> {
Ok(self.observe_payload(id, &ScreenTarget::default()).await?.png) Ok(self
.observe_payload(id, &ScreenTarget::default())
.await?
.png)
} }
pub async fn observe_payload(&self, id: &str, target: &ScreenTarget) -> Result<ObservePayload, String> { pub async fn observe_payload(
if let Ok(value) = self.control_observe_json(id, target).await { &self,
return ObservePayload::from_json(value); id: &str,
} target: &ScreenTarget,
let (stdout, stderr, code) = self ) -> Result<ObservePayload, String> {
.exec_raw(id, &screenshot_command_on(&target.display), None, target) let mut body = if let Ok(value) = self.control_observe_json(id, target).await {
.await?; value
if code != 0 { } else {
return Err(String::from_utf8_lossy(&stderr).into_owned()); let (stdout, stderr, code) = self
} .exec_raw(id, &screenshot_command_on(&target.display), None, target)
let mut body = serde_json::json!({ .await?;
"png_base64": base64::engine::general_purpose::STANDARD.encode(&stdout) if code != 0 {
}); return Err(String::from_utf8_lossy(&stderr).into_owned());
if let Ok(meta) = self.pointer_state(id, target).await { }
if let serde_json::Value::Object(map) = meta { let mut body = serde_json::json!({
if let Some(obj) = body.as_object_mut() { "png_base64": base64::engine::general_purpose::STANDARD.encode(&stdout)
if let (Some(x), Some(y)) = (map.get("x"), map.get("y")) { });
obj.insert("cursor".into(), serde_json::json!({ "x": x, "y": y })); if let Ok(meta) = self.pointer_state(id, target).await {
} if let serde_json::Value::Object(map) = meta {
if map.get("id").and_then(serde_json::Value::as_str).is_some_and(|id| !id.is_empty()) { if let Some(obj) = body.as_object_mut() {
obj.insert( if let (Some(x), Some(y)) = (map.get("x"), map.get("y")) {
"activeWindow".into(), obj.insert("cursor".into(), serde_json::json!({ "x": x, "y": y }));
serde_json::json!({ "id": map.get("id"), "title": map.get("title") }), }
); if map
.get("id")
.and_then(serde_json::Value::as_str)
.is_some_and(|id| !id.is_empty())
{
obj.insert(
"activeWindow".into(),
serde_json::json!({ "id": map.get("id"), "title": map.get("title") }),
);
}
} }
} }
} }
} body
};
self.attach_window_elements(id, target, &mut body).await;
ObservePayload::from_json(body) ObservePayload::from_json(body)
} }
async fn pointer_state(&self, id: &str, target: &ScreenTarget) -> Result<serde_json::Value, String> { async fn attach_window_elements(
&self,
id: &str,
target: &ScreenTarget,
body: &mut serde_json::Value,
) {
if body
.get("elements")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| !items.is_empty())
{
return;
}
let Ok(result) = self
.exec_argv(id, &window_list_command_on(&target.display), None, target)
.await
else {
return;
};
if result.code != 0 {
return;
}
if let Ok(elements) = serde_json::from_str::<serde_json::Value>(&result.stdout) {
body["elements"] = elements;
}
}
async fn pointer_state(
&self,
id: &str,
target: &ScreenTarget,
) -> Result<serde_json::Value, String> {
let result = self let result = self
.exec_argv(id, &pointer_state_command_on(&target.display), None, target) .exec_argv(id, &pointer_state_command_on(&target.display), None, target)
.await?; .await?;
@ -354,28 +406,49 @@ impl DockerHost {
self.screen_url_for(id, 0, interactive).await self.screen_url_for(id, 0, interactive).await
} }
pub async fn screen_url_for(&self, id: &str, slot: u32, interactive: bool) -> Result<String, String> { pub async fn screen_url_for(
&self,
id: &str,
slot: u32,
interactive: bool,
) -> Result<String, String> {
let layout = screen_layout(slot).map_err(|error| error.to_string())?; let layout = screen_layout(slot).map_err(|error| error.to_string())?;
let key = format!("{}/tcp", layout.view_port); let port = self.published_host_port(id, layout.view_port).await?;
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
let port = info
.network_settings
.and_then(|settings| settings.ports)
.and_then(|ports| ports.get(&key).cloned())
.and_then(|bindings| bindings)
.and_then(|bindings| bindings.into_iter().next())
.and_then(|binding| binding.host_port)
.ok_or_else(|| format!("screen port {} is not published", layout.view_port))?;
let view = if interactive { "false" } else { "true" }; let view = if interactive { "false" } else { "true" };
Ok(format!( Ok(format!(
"http://127.0.0.1:{port}/vnc_lite.html?resize=scale&view_only={view}" "http://127.0.0.1:{port}/vnc_lite.html?resize=scale&view_only={view}"
)) ))
} }
async fn published_host_port(&self, id: &str, view_port: u16) -> Result<String, String> {
let key = format!("{view_port}/tcp");
for _ in 0..20 {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
if !running {
let exit = info.state.and_then(|state| state.exit_code).unwrap_or(1);
return Err(format!("computer is not running (exit {exit})"));
}
if let Some(port) = info
.network_settings
.and_then(|settings| settings.ports)
.and_then(|ports| ports.get(&key).cloned())
.flatten()
.and_then(|bindings| bindings.into_iter().next())
.and_then(|binding| binding.host_port)
.filter(|port| !port.is_empty())
{
return Ok(port);
}
sleep(Duration::from_millis(100)).await;
}
Err(format!("screen port {view_port} is not published"))
}
pub async fn ensure_screen( pub async fn ensure_screen(
&self, &self,
id: &str, id: &str,
@ -478,7 +551,12 @@ PY"#,
"python3 -c \"import os,base64,sys; p=sys.argv[1]; os.makedirs(os.path.dirname(p) or '.', exist_ok=True); open(p,'wb').write(base64.b64decode(sys.argv[2]))\" {target:?} {encoded:?}" "python3 -c \"import os,base64,sys; p=sys.argv[1]; os.makedirs(os.path.dirname(p) or '.', exist_ok=True); open(p,'wb').write(base64.b64decode(sys.argv[2]))\" {target:?} {encoded:?}"
); );
let result = self let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, &ScreenTarget::default()) .exec_argv(
id,
&["bash".into(), "-lc".into(), script],
None,
&ScreenTarget::default(),
)
.await?; .await?;
if result.code != 0 { if result.code != 0 {
Err(result.stderr) Err(result.stderr)
@ -538,7 +616,11 @@ PY"#,
return Ok(false); return Ok(false);
} }
let running = info.state.as_ref().and_then(|state| state.running) == Some(true); let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
let exit = info.state.as_ref().and_then(|state| state.exit_code).unwrap_or(0); let exit = info
.state
.as_ref()
.and_then(|state| state.exit_code)
.unwrap_or(0);
if !running && exit != 0 { if !running && exit != 0 {
return Ok(false); return Ok(false);
} }
@ -592,6 +674,33 @@ PY"#,
Err("container failed to start".into()) Err("container failed to start".into())
} }
async fn wait_ready(&self, id: &str) -> Result<(), String> {
for _ in 0..160 {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
if info.state.as_ref().and_then(|state| state.running) != Some(true) {
let exit = info.state.and_then(|state| state.exit_code).unwrap_or(1);
return Err(format!("computer exited during startup with code {exit}"));
}
match self
.exec_argv(
id,
&["test".into(), "-f".into(), "/tmp/lazyboy/ready".into()],
None,
&ScreenTarget::default(),
)
.await
{
Ok(result) if result.code == 0 => return Ok(()),
Ok(_) | Err(_) => sleep(Duration::from_millis(250)).await,
}
}
Err("computer desktop failed to become ready".into())
}
async fn exec_argv( async fn exec_argv(
&self, &self,
id: &str, id: &str,
@ -643,14 +752,23 @@ PY"#,
let mut stderr = Vec::new(); let mut stderr = Vec::new();
if let StartExecResults::Attached { mut output, .. } = self if let StartExecResults::Attached { mut output, .. } = self
.docker .docker
.start_exec(&exec.id, Some(StartExecOptions { ..Default::default() })) .start_exec(
&exec.id,
Some(StartExecOptions {
..Default::default()
}),
)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
{ {
while let Some(chunk) = output.next().await { while let Some(chunk) = output.next().await {
match chunk.map_err(|error| error.to_string())? { match chunk.map_err(|error| error.to_string())? {
bollard::container::LogOutput::StdOut { message } => stdout.extend_from_slice(&message), bollard::container::LogOutput::StdOut { message } => {
bollard::container::LogOutput::StdErr { message } => stderr.extend_from_slice(&message), stdout.extend_from_slice(&message)
}
bollard::container::LogOutput::StdErr { message } => {
stderr.extend_from_slice(&message)
}
_ => {} _ => {}
} }
} }
@ -663,11 +781,14 @@ PY"#,
Ok((stdout, stderr, inspect.exit_code.unwrap_or(1) as i32)) Ok((stdout, stderr, inspect.exit_code.unwrap_or(1) as i32))
} }
async fn control_observe_json(&self, id: &str, target: &ScreenTarget) -> Result<serde_json::Value, String> { async fn control_observe_json(
&self,
id: &str,
target: &ScreenTarget,
) -> Result<serde_json::Value, String> {
let script = format!( let script = format!(
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe", "curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe",
self.control_token, self.control_token, target.display
target.display
); );
let result = self let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target) .exec_argv(id, &["bash".into(), "-lc".into(), script], None, target)
@ -709,7 +830,9 @@ PY"#,
fn image_ids_match(wanted: &str, have: &str) -> bool { fn image_ids_match(wanted: &str, have: &str) -> bool {
let wanted = wanted.trim_start_matches("sha256:"); let wanted = wanted.trim_start_matches("sha256:");
let have = have.trim_start_matches("sha256:"); let have = have.trim_start_matches("sha256:");
!wanted.is_empty() && !have.is_empty() && (wanted == have || wanted.starts_with(have) || have.starts_with(wanted)) !wanted.is_empty()
&& !have.is_empty()
&& (wanted == have || wanted.starts_with(have) || have.starts_with(wanted))
} }
fn host_bind_path(path: &str) -> String { fn host_bind_path(path: &str) -> String {

View File

@ -8,7 +8,8 @@ use axum::routing::{delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use docker::DockerHost; use docker::DockerHost;
use lazyboy_control::{ use lazyboy_control::{
normalize_workspace_path, ActionRequest, CommandRequest, EnsureScreenRequest, ScreenTarget, HOME, ActionRequest, CommandRequest, EnsureScreenRequest, HOME, ScreenTarget,
normalize_workspace_path,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -34,13 +35,14 @@ async fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap())) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init(); .init();
let token = std::env::var("SANDBOX_SUPERVISOR_TOKEN") let token =
.expect("SANDBOX_SUPERVISOR_TOKEN must be set"); std::env::var("SANDBOX_SUPERVISOR_TOKEN").expect("SANDBOX_SUPERVISOR_TOKEN must be set");
assert!( assert!(
token.len() >= 32 && token != "dev-token", token.len() >= 32 && token != "dev-token",
"SANDBOX_SUPERVISOR_TOKEN must be a non-default value of at least 32 characters" "SANDBOX_SUPERVISOR_TOKEN must be a non-default value of at least 32 characters"
); );
let image = std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into()); let image =
std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into());
let docker = DockerHost::connect(image, token.clone()) let docker = DockerHost::connect(image, token.clone())
.await .await
.expect("docker"); .expect("docker");
@ -49,7 +51,10 @@ async fn main() {
docker: Arc::new(docker), docker: Arc::new(docker),
}; };
let router = Router::new() let router = Router::new()
.route("/health", get(|| async { Json(serde_json::json!({"ok": true})) })) .route(
"/health",
get(|| async { Json(serde_json::json!({"ok": true})) }),
)
.route("/computers", post(provision)) .route("/computers", post(provision))
.route("/computers/{id}/exec", post(exec)) .route("/computers/{id}/exec", post(exec))
.route("/computers/{id}/observe", post(observe)) .route("/computers/{id}/observe", post(observe))
@ -120,15 +125,15 @@ async fn exec(
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(body): Json<CommandRequest>, Json(body): Json<CommandRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token).map_err(|status| (status, String::new()))?;
let result = app let result = app
.docker .docker
.exec_on(&id, body, &screen_target(&headers)) .exec_on(&id, body, &screen_target(&headers))
.await .await
.map_err(|error| { .map_err(|error| {
tracing::error!("exec: {error}"); tracing::error!("exec: {error}");
StatusCode::INTERNAL_SERVER_ERROR (StatusCode::INTERNAL_SERVER_ERROR, error)
})?; })?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"stdout": result.stdout, "stdout": result.stdout,
@ -190,7 +195,10 @@ async fn screen_mode(
Json(body): Json<ScreenModeBody>, Json(body): Json<ScreenModeBody>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
let slot = body.slot.or(screen_target(&headers).slot.into()).unwrap_or(0); let slot = body
.slot
.or(screen_target(&headers).slot.into())
.unwrap_or(0);
let url = app let url = app
.docker .docker
.screen_url_for(&id, slot, body.interactive) .screen_url_for(&id, slot, body.interactive)
@ -286,7 +294,10 @@ async fn stop(
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
app.docker.stop(&id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; app.docker
.stop(&id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -296,7 +307,10 @@ async fn destroy(
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
app.docker.destroy(&id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; app.docker
.destroy(&id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@ -58,6 +58,8 @@ services:
HOST_DATA_DIR: ${PWD}/data HOST_DATA_DIR: ${PWD}/data
API_BIND: 0.0.0.0:3100 API_BIND: 0.0.0.0:3100
XAI_API_KEY: ${XAI_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-}
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true} LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true}
LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8} LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}

View File

@ -15,9 +15,25 @@ COPY apps/web apps/web
RUN cargo build --release -p lazyboy-api RUN cargo build --release -p lazyboy-api
FROM debian:trixie-slim FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates python3 python3-pip python3-lxml \
&& pip3 install --break-system-packages --no-cache-dir mcp-server-fetch \
&& rm -rf /var/lib/apt/lists/*
COPY --from=web /usr/local/bin/node /usr/local/bin/node
COPY --from=web /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
COPY --from=build /src/target/release/lazyboy-api /usr/local/bin/lazyboy-api COPY --from=build /src/target/release/lazyboy-api /usr/local/bin/lazyboy-api
COPY --from=web /src/apps/web/dist /web COPY --from=web /src/apps/web/dist /web
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& npm install -g --omit=dev \
@modelcontextprotocol/server-memory \
@modelcontextprotocol/server-sequential-thinking \
@notionhq/notion-mcp-server \
&& npm cache clean --force \
&& node -v && npx --version \
&& python3 -c "import mcp_server_fetch"
ENV LAZYBOY_WEB_DIR=/web ENV LAZYBOY_WEB_DIR=/web
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
ENV NPM_CONFIG_FUND=false
EXPOSE 3100 EXPOSE 3100
CMD ["lazyboy-api"] CMD ["lazyboy-api"]

View File

@ -84,7 +84,6 @@ RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
&& chown -R 1000:1000 /home/lazyboy /tmp/lazyboy && chown -R 1000:1000 /home/lazyboy /tmp/lazyboy
COPY --from=controld --chmod=755 /src/target/release/lazyboy-controld /usr/local/bin/lazyboy-controld COPY --from=controld --chmod=755 /src/target/release/lazyboy-controld /usr/local/bin/lazyboy-controld
COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen 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-browser /usr/local/bin/lazyboy-browser
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal
@ -129,6 +128,7 @@ COPY --chmod=644 image/computer/xfce/helper-terminal.desktop /usr/share/xfce4/he
COPY --chmod=644 image/computer/xfce/helper-browser.desktop /usr/share/xfce4/helpers/lazyboy-browser.desktop COPY --chmod=644 image/computer/xfce/helper-browser.desktop /usr/share/xfce4/helpers/lazyboy-browser.desktop
COPY --chmod=755 image/computer/chromium /usr/local/bin/chromium 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 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
USER 1000:1000 USER 1000:1000
ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \ ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \

View File

@ -11,6 +11,7 @@ export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}" export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}" export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix
rm -f /tmp/lazyboy/ready
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
cd "$HOME" cd "$HOME"
@ -23,6 +24,7 @@ if command -v dbus-launch >/dev/null 2>&1; then
fi fi
lazyboy-screen boot-primary || exit 1 lazyboy-screen boot-primary || exit 1
touch /tmp/lazyboy/ready
pid="" pid=""
if [[ -f /tmp/lazyboy/xvfb-1.pid ]]; then if [[ -f /tmp/lazyboy/xvfb-1.pid ]]; then
pid="$(cat /tmp/lazyboy/xvfb-1.pid)" pid="$(cat /tmp/lazyboy/xvfb-1.pid)"

View File

@ -0,0 +1,3 @@
ALTER TABLE spaces
ADD COLUMN IF NOT EXISTS default_model_base_url TEXT,
ADD COLUMN IF NOT EXISTS default_model_api_key TEXT;