add new skin

This commit is contained in:
王性驊 2026-09-04 00:16:34 +08:00
parent e35bad522f
commit 7b76d00d7d
24 changed files with 2509 additions and 303 deletions

View File

@ -22,4 +22,7 @@ cargo run -p lazyboy-api
Open `http://<host>:3101`. The computer is a real Debian container: fluxbox toolbar, Chromium with tabs and URL bar, and xterm. Do not replace that with a kiosk or HTML landing page. The display is proxied through the API so you do not open extra ports. Open `http://<host>:3101`. The computer is a real Debian container: fluxbox toolbar, Chromium with tabs and URL bar, and xterm. Do not replace that with a kiosk or HTML landing page. The display is proxied through the API so you do not open extra ports.
For frontend development, run `npm install && npm run dev` in `apps/web`, then open
`http://127.0.0.1:5173`. Vite proxies API and computer-screen traffic to the Rust API on port 3101.
Model providers: v1 talks to xAI (`XAI_API_KEY`). `openai` / `anthropic` / `openrouter` are reserved on the factory and return `unsupported_provider`. Model providers: v1 talks to xAI (`XAI_API_KEY`). `openai` / `anthropic` / `openrouter` are reserved on the factory and return `unsupported_provider`.

View File

@ -1,241 +1 @@
<!doctype html> <!doctype html><html lang="zh-Hant"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#050506"/><title>LazyBoy</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LazyBoy</title>
<style>
:root {
color-scheme: dark;
--bg: #111113;
--panel: #1a1a1f;
--line: #2a2a32;
--text: #ececf1;
--muted: #9a9aa8;
--accent: #6ea8ff;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 14px/1.45 ui-sans-serif, system-ui, sans-serif; }
#app { display: grid; grid-template-columns: 240px 1fr 1.2fr; height: 100%; }
aside, main, section { border-right: 1px solid var(--line); min-width: 0; min-height: 0; }
section { border-right: none; display: flex; flex-direction: column; }
header { padding: 16px; border-bottom: 1px solid var(--line); }
h1, h2 { margin: 0; font-size: 15px; font-weight: 600; }
.list { padding: 8px; overflow: auto; }
button, input, textarea, select {
font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
}
button { padding: 8px 10px; cursor: pointer; }
button.primary { background: var(--accent); color: #081018; border-color: transparent; font-weight: 600; }
input, textarea, select { padding: 8px; width: 100%; }
.bot { padding: 10px; border-radius: 8px; cursor: pointer; }
.bot.active, .bot:hover { background: var(--panel); }
.messages { padding: 16px; overflow: auto; height: calc(100% - 140px); }
.msg { margin: 0 0 12px; }
.msg .who { color: var(--muted); font-size: 12px; }
.composer { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); }
.composer textarea { min-height: 52px; resize: none; }
iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: #1d4ed8; pointer-events: auto; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.create { padding: 12px; display: grid; gap: 8px; }
.hint { color: var(--muted); padding: 12px; }
</style>
</head>
<body>
<div id="app">
<aside>
<header><h1>LazyBoy</h1></header>
<div class="create">
<input id="name" placeholder="Bot name" />
<select id="mode">
<option value="team">Team computer (shared)</option>
<option value="dedicated">Private computer (new Docker)</option>
</select>
<button class="primary" id="create">Create bot</button>
<div class="hint" style="padding:0">Team is one machine with a screen per bot. Private boots an isolated container.</div>
</div>
<div class="list" id="bots"></div>
</aside>
<main>
<header><h2 id="chatTitle">Chat</h2></header>
<div class="messages" id="messages"></div>
<div class="composer">
<textarea id="draft" placeholder="Message the bot"></textarea>
<button class="primary" id="send">Send</button>
</div>
</main>
<section>
<header>
<h2>Computer</h2>
<div class="row" style="margin-top:8px">
<button id="boot">Open desktop</button>
<button id="restart">Restart</button>
<button id="stop">Shut down</button>
<button id="takeover">Take control</button>
<button id="release">Release</button>
</div>
<div class="hint" id="status">No computer yet</div>
</header>
<iframe id="screen" title="Bot desktop" allow="fullscreen; clipboard-read; clipboard-write"></iframe>
</section>
</div>
<script>
const api = (path, opts = {}) =>
fetch(path, {
headers: { "content-type": "application/json", ...(opts.headers || {}) },
...opts,
}).then(async (res) => {
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { message: text }; }
if (!res.ok) throw new Error(body && body.message ? body.message : `${res.status} ${text}`);
return body;
});
let bots = [];
let active = null;
let poll = null;
async function refreshBots() {
bots = await api("/api/bots");
const root = document.getElementById("bots");
root.innerHTML = bots
.map(
(bot) =>
`<div class="bot ${active && active.id === bot.id ? "active" : ""}" data-id="${bot.id}">
<strong>${bot.name}</strong><div class="who">${bot.computerMode}</div>
</div>`
)
.join("");
root.querySelectorAll(".bot").forEach((el) =>
el.addEventListener("click", () => select(bots.find((b) => b.id === el.dataset.id)))
);
}
async function select(bot) {
active = bot;
document.getElementById("chatTitle").textContent = bot.name;
await refreshBots();
await refreshThread();
await refreshComputer();
if (poll) clearInterval(poll);
poll = setInterval(() => {
refreshThread().catch(() => {});
refreshComputer().catch(() => {});
ping().catch(() => {});
}, 2000);
}
async function refreshThread() {
if (!active) return;
const messages = await api(`/api/bots/${active.id}/messages`);
const root = document.getElementById("messages");
root.innerHTML = messages
.map((msg) => `<div class="msg"><div class="who">${msg.role}</div><div>${escapeHtml(msg.body || "")}</div></div>`)
.join("");
root.scrollTop = root.scrollHeight;
}
async function refreshComputer() {
if (!active) return;
const status = await api(`/api/computer/${active.id}/status`);
const busy = status.busyBotName ? ` · ${status.busyBotName}` : "";
const share = status.mode === "team" ? "Team computer" : "Private computer";
const display = status.display ? ` · ${status.display}` : "";
const profile = status.profileMode ? ` · profile ${status.profileMode}` : "";
const ask = status.takeoverRequested ? " · bot asked you to take control" : "";
document.getElementById("status").textContent = `${share}${display}${profile} · ${status.state} · control ${status.controlHolder}${busy}${ask}`;
const desktop = await api(`/api/computer/${active.id}/screen`).catch(() => ({ url: null }));
const frame = document.getElementById("screen");
if (desktop.url) {
const next = new URL(desktop.url, window.location.origin).href;
if (frame.src !== next) frame.src = next;
}
}
async function ping() {
if (!active) return;
await api(`/api/computer/${active.id}/heartbeat`, { method: "POST", body: "{}" }).catch(() => {});
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
document.getElementById("create").onclick = async () => {
const name = document.getElementById("name").value.trim();
if (!name) return;
const bot = await api("/api/bots", {
method: "POST",
body: JSON.stringify({
name,
computerMode: document.getElementById("mode").value,
}),
});
document.getElementById("name").value = "";
await refreshBots();
await select(bot);
};
document.getElementById("send").onclick = async () => {
if (!active) return;
const text = document.getElementById("draft").value.trim();
if (!text) return;
document.getElementById("draft").value = "";
await api(`/api/bots/${active.id}/messages`, { method: "POST", body: JSON.stringify({ text }) });
await refreshThread();
};
document.getElementById("boot").onclick = async () => {
if (!active) return;
await api(`/api/computer/${active.id}/boot`, { method: "POST", body: "{}" });
await api(`/api/computer/${active.id}/takeover`, { method: "POST", body: "{}" }).catch(() => {});
await refreshComputer();
};
document.getElementById("restart").onclick = async () => {
if (!active) return;
document.getElementById("status").textContent = "Restarting computer…";
document.getElementById("screen").src = "about:blank";
await api(`/api/computer/${active.id}/restart`, { method: "POST", body: "{}" });
await api(`/api/computer/${active.id}/takeover`, { method: "POST", body: "{}" }).catch(() => {});
await refreshComputer();
};
document.getElementById("takeover").onclick = async () => {
if (!active) return;
try {
await api(`/api/computer/${active.id}/takeover`, { method: "POST", body: "{}" });
} catch (error) {
document.getElementById("status").textContent = error.message;
}
await refreshComputer();
};
document.getElementById("release").onclick = async () => {
if (!active) return;
await api(`/api/computer/${active.id}/release`, { method: "POST", body: "{}" });
await refreshComputer();
};
document.getElementById("stop").onclick = async () => {
if (!active) return;
await api(`/api/computer/${active.id}/stop`, { method: "POST", body: "{}" });
document.getElementById("screen").src = "about:blank";
await refreshComputer();
};
window.addEventListener("message", (event) => {
if (!active || !event.data || event.data.type !== "lazyboy-request-control") return;
api(`/api/computer/${active.id}/takeover`, { method: "POST", body: "{}" })
.then(() => refreshComputer())
.catch((error) => {
document.getElementById("status").textContent = error.message;
});
});
refreshBots().catch((error) => {
document.getElementById("status").textContent = error.message;
});
</script>
</body>
</html>

1910
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

1
apps/web/package.json Normal file
View File

@ -0,0 +1 @@
{"name":"lazyboy-web","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"tsc --noEmit && vite build","typecheck":"tsc --noEmit --pretty false"},"dependencies":{"@fontsource/huninn":"^5.3.0","blobshape":"^1.0.0","lucide-react":"^0.468.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","typescript":"^5.7.2","vite":"^6.0.7"}}

80
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,80 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { ArrowUp, Bot as BotIcon, ChevronDown, Clipboard, ClipboardPaste, Computer, Ellipsis, Eye, EyeOff, FolderPlus, LoaderCircle, Mail, Menu, Pin, Plus, Search, Settings, Square, Trash2, X } from "lucide-react";
import { api } from "./api";
import { t } from "./i18n";
import blobshape from "blobshape";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, Message } from "./types";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,display:null,profileMode:"per-bot",screenAvailable:false};
const botColors=["#3ec5a8","#f5a03c","#6a6bf5","#9b5cf6","#3b82f6","#d9508a"];
function Avatar({name,color,shape="blob",active=false,thinking=false,size=32}:{name:string;color?:string;shape?:AvatarShape;active?:boolean;thinking?: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":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></span>}
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
function 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)}
export function App(){
const [bots,setBots]=useState<Bot[]>([]); const [activeId,setActiveId]=useState<string|null>(null);
const [messages,setMessages]=useState<Message[]>([]); const [computer,setComputer]=useState<ComputerStatus>(blankComputer);
const [screenUrl,setScreenUrl]=useState<string|null>(null); const [draft,setDraft]=useState(""); const [query,setQuery]=useState("");
const [createOpen,setCreateOpen]=useState(false); const [settingsOpen,setSettingsOpen]=useState(false); const [deleteOpen,setDeleteOpen]=useState(false); const [computerOpen,setComputerOpen]=useState(false);
const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState<string|null>(null); const [busy,setBusy]=useState(false);
const [desktopClipboard,setDesktopClipboard]=useState(""); const active=bots.find(b=>b.id===activeId)||null;
const [clipboardOpen,setClipboardOpen]=useState(false);
const [showHidden,setShowHidden]=useState(false);const[context,setContext]=useState<{bot:Bot;x:number;y:number}|null>(null);
const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]);
const sections=useMemo(()=>{const map=new Map<string,Bot[]>();for(const bot of filtered){const key=bot.pinned?"已釘選":bot.groupName||"Agent";map.set(key,[...(map.get(key)||[]),bot])}return [...map.entries()]},[filtered]);
const loadBots=useCallback(async()=>{const next=await api<Bot[]>("/api/bots");setBots(next);setActiveId(id=>id&&next.some(b=>b.id===id)?id:next[0]?.id||null)},[]);
const refresh=useCallback(async()=>{if(!activeId)return;const [nextMessages,nextComputer,screen]=await Promise.all([api<Message[]>(`/api/bots/${activeId}/messages`),api<ComputerStatus>(`/api/computer/${activeId}/status`),api<{url:string|null}>(`/api/computer/${activeId}/screen`).catch(()=>({url:null}))]);setMessages(nextMessages);setComputer(nextComputer);setScreenUrl(screen.url)},[activeId]);
useEffect(()=>{loadBots().catch(e=>setError(e.message))},[loadBots]);
useEffect(()=>{if(!activeId){setMessages([]);setComputer(blankComputer);return}refresh().catch(e=>setError(e.message));const timer=setInterval(()=>{refresh().catch(()=>{});api(`/api/computer/${activeId}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>clearInterval(timer)},[activeId,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 close=()=>setContext(null);window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
async function action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?e.message:"操作失敗")}finally{setBusy(false)}}
async function send(event:FormEvent){event.preventDefault();const text=draft.trim();if(!active||!text)return;setDraft("");await action(()=>api(`/api/bots/${active.id}/messages`,{method:"POST",body:JSON.stringify({text})}))}
async function pasteClipboard(){try{const text=await navigator.clipboard.readText();document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin))}catch{setClipboardOpen(true)}}
async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError("瀏覽器封鎖剪貼簿寫入。")}}
async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()}
function openBot(bot:Bot){setActiveId(bot.id);setMobileNav(false);if(bot.unreadCount>0)void inbox(bot,"read")}
const frame=screenUrl?<iframe className="desktop-frame" src={screenUrl} title="Agent computer" allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
return <div className="app-shell">
<aside className={`sidebar ${mobileNav?"open":""}`}>
<div className="brand"><span>LazyBoy</span><button className="icon-button" onClick={()=>setCreateOpen(true)} aria-label="新增機器人"><Plus/></button></div>
<label className="search"><Search/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("search")}/></label>
<div className="bot-list">{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?"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}/>{bot.unreadCount>0&&<i className="unread-dot" title={`${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>
<button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<EyeOff/>:<Eye/>}{showHidden?"隱藏已隱藏項目":"顯示已隱藏項目"}</button>
<div className="sidebar-bottom"><button className="account"><Avatar name="L"/><span>Local workspace</span><ChevronDown/></button></div>
</aside>
<main className="chat-panel">
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><Menu/></button>{active?<><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active/><strong>{active.name}</strong><span className="grow"/><button className="icon-button" onClick={()=>setSettingsOpen(true)} title="機器人設定"><Settings/></button><button className="icon-button computer-toggle" onClick={()=>setComputerOpen(true)} title="開啟電腦"><Computer/></button><button className="icon-button danger-ghost" onClick={()=>setDeleteOpen(true)} title="刪除機器人"><Trash2/></button></>:<strong></strong>}</header>
<div className="messages">{active&&messages.length===0?<div className="welcome"><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active size={64}/><h1> {active.name} </h1><p>{active.description||"傳送訊息,讓它在自己的電腦上完成任務。"}</p></div>:messages.map(message=><div key={message.id} className={`message ${message.role}`}><span>{message.body}</span></div>)}{active&&computer.busyBotName&&<div className="thinking-row"><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active/><span className="thinking-dots" aria-label="正在思考"><i/><i/><i/></span></div>}</div>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
<form className="composer" onSubmit={send}><button type="button" className="composer-plus"><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();e.currentTarget.form?.requestSubmit()}}} placeholder={active?`傳訊息給 ${active.name}`:"先選擇機器人"} disabled={!active}/>{computer.busyBotName?<button type="button" className="send stop-send" title="停止對話" onClick={()=>active&&action(()=>api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"}))}><Square/></button>:<button className="send" disabled={!active||!draft.trim()||busy}><ArrowUp/></button>}</form>
</main>
<aside className="computer-panel"><ComputerHeader active={active} computer={computer}/><div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}</div>{active&&<><div className="computer-caption"><span>{active.name} </span><button className="outline" onClick={()=>setComputerOpen(true)}></button></div><ControlBar active={active} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard}/></>}</aside>
{computerOpen&&active&&<div className="computer-overlay"><header><div><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active/><strong>{modeLabel(computer.mode)}</strong><span className="control-badge"></span></div><div><ControlButtons computer={computer} busy={busy} action={action} active={active}/><button className="icon-button" onClick={pasteClipboard} title="貼上剪貼簿"><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={!desktopClipboard} title="複製桌面剪貼簿"><Clipboard/></button><button className="icon-button" title="更多操作"><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)}}/>}
{settingsOpen&&active&&<BotSettingsDialog bot={active} close={()=>setSettingsOpen(false)} saved={async()=>{setSettingsOpen(false);await loadBots()}}/>}
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
{deleteOpen&&active&&<ConfirmDelete bot={active} close={()=>setDeleteOpen(false)} confirm={()=>action(async()=>{await api(`/api/bots/${active.id}`,{method:"DELETE"});setDeleteOpen(false);setActiveId(null);await loadBots()})}/>}
{context&&<BotContextMenu context={context} close={()=>setContext(null)} run={async(actionName,group)=>{setContext(null);if(actionName==="delete"){setActiveId(context.bot.id);setDeleteOpen(true);return}await inbox(context.bot,actionName,group)}}/>}
</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?"取消釘選":"釘選"}</button><button onClick={()=>run("unread")}><Mail/></button><button onClick={()=>{const name=window.prompt("輸入分組名稱",bot.groupName||"");if(name!==null)run("group",name)}}><FolderPlus/>{bot.groupName?"變更分組":"建立/移入分組"}</button>{bot.groupName&&<button onClick={()=>run("group",null)}><X/></button>}<button onClick={()=>run(bot.hidden?"show":"hide")}>{bot.hidden?<Eye/>:<EyeOff/>}{bot.hidden?"取消隱藏":"隱藏"}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><Trash2/></button><button className="context-close" onClick={close}><X/></button></div>}
function BotSettingsDialog({bot,close,saved}:{bot:Bot;close:()=>void;saved:()=>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>(bot.avatarShape||"blob");const[tagText,setTagText]=useState((bot.tags||[]).join("、"));const[busy,setBusy]=useState(false);const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];const shapes:AvatarShape[]=["round","blob","squircle","capsule","triangle","hexagon","cloud","drop"];return <div className="modal-backdrop"><form className="dialog settings-dialog" 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)})});saved()}finally{setBusy(false)}}}><div className="dialog-title"><h2></h2><button type="button" onClick={close}><X/></button></div><div className="avatar-editor"><Avatar name={name||bot.name} color={color} shape={shape} size={78}/><strong></strong><small>使</small></div><fieldset><legend></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={`選擇 ${value}`}/>) }<label className="custom-color" title="自訂顏色"><input type="color" value={color} onChange={e=>setColor(e.target.value.toUpperCase())}/><span></span></label></div></fieldset><fieldset><legend></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><label><input value={name} maxLength={80} onChange={e=>setName(e.target.value)}/></label><label><input value={tagText} maxLength={120} onChange={e=>setTagText(e.target.value)} placeholder="研究、設計、客服(用逗號分隔)"/><small> 6 </small></label><label><input value={title} maxLength={100} onChange={e=>setTitle(e.target.value)} placeholder="例如:產品研究助理"/></label><label><textarea value={description} maxLength={1000} rows={4} onChange={e=>setDescription(e.target.value)} placeholder="說明這個機器人的用途與工作範圍"/></label><div className="dialog-actions"><button type="button" className="outline" onClick={close}></button><button className="primary" disabled={busy||!name.trim()}>{busy?"儲存中…":"儲存設定"}</button></div></form></div>}
function ComputerHeader({active,computer}:{active:Bot|null;computer:ComputerStatus}){const loading=computer.state==="booting";return <header className="panel-head"><span>{active?`${active.name} 的電腦`:"電腦"}</span>{loading?<LoaderCircle className="spinner"/>:<i className={`state-dot ${computer.state}`}/>}<small>{stateLabel(computer.state)}</small></header>}
function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<LoaderCircle className="spinner large"/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?"正在準備 Agent 的獨立桌面…":"開啟電腦後,畫面會顯示在這裡。"}</span></div>}
function ControlButtons({computer,busy,action,active}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot}){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")&&<LoaderCircle className="spinner"/>}{computer.state==="booting"?"啟動中…":t("openComputer")}</button>;return working?<button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"}))}><Square/>{t("stopTask")}</button>:null}
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void}){return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" onClick={props.copy}><Clipboard/></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></h2><button onClick={close}><X/></button></div><p> VNC HTTP 使</p><textarea className="clipboard-text" autoFocus value={text} onChange={e=>setText(e.target.value)} placeholder="在此貼上文字…"/><div className="dialog-actions"><button className="outline" onClick={close}></button><button className="primary" disabled={!text} onClick={()=>paste(text)}> VNC</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></h2><button type="button" onClick={close}><X/></button></div><label><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="例如:研究助理"/></label><div className="mode-grid"><button type="button" className={mode==="team"?"picked":""} onClick={()=>setMode("team")}><BotIcon/><strong></strong><small></small></button><button type="button" className={mode==="dedicated"?"picked":""} onClick={()=>setMode("dedicated")}><Computer/><strong></strong><small> Docker</small></button></div><div className="dialog-actions"><button type="button" className="outline" onClick={close}></button><button className="primary" disabled={busy||!name.trim()}></button></div></form></div>}
function ConfirmDelete({bot,close,confirm}:{bot:Bot;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2> {bot.name}</h2><p>{bot.computerMode==="dedicated"?"對話、私人電腦與其中的檔案都會永久刪除。":"對話會刪除,但共用電腦與其中的檔案會保留。"}</p><div className="dialog-actions"><button className="outline" onClick={close}></button><button className="danger" onClick={confirm}></button></div></div></div>}

7
apps/web/src/api.ts Normal file
View File

@ -0,0 +1,7 @@
export async function api<T>(path:string, options:RequestInit={}):Promise<T>{
const response=await fetch(path,{...options,headers:{"content-type":"application/json",...options.headers}});
const text=await response.text(); let body:unknown=null;
try{body=text?JSON.parse(text):null}catch{body={message:text}}
if(!response.ok){const message=typeof body==="object"&&body&&"message" in body?String((body as {message:unknown}).message):`${response.status} ${response.statusText}`;throw new Error(message)}
return body as T;
}

3
apps/web/src/blobshape.d.ts vendored Normal file
View File

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

25
apps/web/src/i18n.ts Normal file
View File

@ -0,0 +1,25 @@
export const supportedLocales = ["zh-TW"] as const;
export type Locale = (typeof supportedLocales)[number];
const messages = {
"zh-TW": {
search: "搜尋",
sharedComputer: "共用電腦",
privateComputer: "私人電腦",
stopped: "已關閉",
booting: "啟動中",
running: "執行中",
suspended: "休眠中",
error: "發生錯誤",
openComputer: "開啟電腦",
stopTask: "停止任務",
takeControl: "取得控制權",
releaseControl: "交還控制",
done: "完成",
skip: "略過",
},
} as const;
export const locale: Locale = "zh-TW";
export type MessageKey = keyof (typeof messages)["zh-TW"];
export function t(key: MessageKey): string { return messages[locale][key]; }

7
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource/huninn";
import "./styles.css";
import "./refinements.css";
import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1,51 @@
.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}
.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:var(--bot-color);box-shadow:none}
.avatar-blob{border-radius:58% 42% 52% 48%/43% 58% 42% 57%;transform:rotate(-3deg)}
.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.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:none}.avatar-shape path{fill:var(--bot-color)}
.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:-1;inset:-7px;border-radius:50%;background:conic-gradient(from 0deg,#ff4fd8,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff7a59,#ff4fd8);filter:blur(1px);animation:magic-orbit 1.35s linear infinite}.avatar.robot.thinking:after{inset:-11px;opacity:.45;filter:blur(7px);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}
.messages{min-width:0}
.message{box-sizing:border-box;width:min(760px,100%);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
.message>span{max-width:82%}
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);margin:4px auto 18px}
.thinking-dots{position:relative;display:flex;align-items:center;gap:4px;height:32px;padding:0 12px;border:1px solid var(--border);border-radius:14px;background:var(--surface);overflow:hidden}.thinking-dots:after{content:"";position:absolute;left:12px;bottom:4px;width:24px;height:2px;border-radius:999px;background:linear-gradient(90deg,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff73dc,#7c5cff);background-size:200% 100%;animation:small-magic-line 1.25s linear infinite}
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}.thinking-dots i:nth-child(2){animation-delay:.16s}.thinking-dots i:nth-child(3){animation-delay:.32s}
.composer{left:50%;right:auto;width:min(760px,calc(100% - 48px));min-height:62px;align-items:center;padding:9px 10px;transform:translateX(-50%)}
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
.stop-send{background:#f2f2f2;color:#151515}.stop-send svg{width:14px;height:14px;fill:currentColor}
.spinner{width:17px;height:17px;animation:spin .8s linear infinite}.spinner.large{width:38px;height:38px;color:var(--accent)}.primary .spinner{margin-right:7px}.primary{display:inline-flex;align-items:center;justify-content:center}
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:#0c0c0d;color:var(--text);padding:12px;font:inherit;line-height:1.5;outline:none}.clipboard-text:focus{border-color:var(--accent)}
.bot-tag{padding:3px 8px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;white-space:nowrap}
.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}
.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)}
@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 spin{to{transform:rotate(360deg)}}
@keyframes magic-orbit{to{transform:rotate(360deg)}}
@keyframes small-magic-line{to{background-position:200% 0}}
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.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}}
.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))}
.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)}

1
apps/web/src/styles.css Normal file

File diff suppressed because one or more lines are too long

6
apps/web/src/types.ts Normal file
View File

@ -0,0 +1,6 @@
export type ComputerMode = "team" | "dedicated";
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";
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 }
export interface Message { id:string; role:string; body:string; createdAt:string }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; display:string|null; profileMode:string; screenAvailable:boolean }

1
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"]}

3
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,3 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({plugins:[react()],build:{outDir:"dist",emptyOutDir:true},server:{port:5173,proxy:{"/api":"http://127.0.0.1:3101","/view":{target:"http://127.0.0.1:3101",ws:true}}}});

View File

@ -57,6 +57,21 @@
let rfb = null; let rfb = null;
let reconnectTimer = null; let reconnectTimer = null;
function pasteIntoDesktop(text) {
if (!rfb || rfb.viewOnly || !text) return;
try { rfb.clipboardPasteFrom(text); } catch (_) {}
// x11vnc applies ClientCutText asynchronously. Give X11 a moment before
// sending Ctrl+V so pastes are reliable for Chromium and terminals.
setTimeout(() => {
try {
rfb.sendKey(0xffe3, "ControlLeft", true);
rfb.sendKey(0x0076, "KeyV", true);
rfb.sendKey(0x0076, "KeyV", false);
rfb.sendKey(0xffe3, "ControlLeft", false);
} catch (_) {}
}, 100);
}
function pinTaskbar() { function pinTaskbar() {
// noVNC centers the scaled canvas (margin:auto), leaving a dead // noVNC centers the scaled canvas (margin:auto), leaving a dead
// strip under the panel. Stick the canvas to the bottom. // strip under the panel. Stick the canvas to the bottom.
@ -93,6 +108,12 @@
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 1500); reconnectTimer = setTimeout(connect, 1500);
}); });
rfb.addEventListener("clipboard", (event) => {
const text = event && event.detail ? event.detail.text : "";
if (typeof text === "string") {
window.parent.postMessage({ type: "lazyboy-desktop-clipboard", text }, "*");
}
});
} }
connect(); connect();
@ -108,8 +129,11 @@
const text = event.clipboardData ? event.clipboardData.getData("text") : ""; const text = event.clipboardData ? event.clipboardData.getData("text") : "";
if (!text) return; if (!text) return;
event.preventDefault(); event.preventDefault();
try { rfb.clipboardPasteFrom(text); } catch (_) {} pasteIntoDesktop(text);
try { rfb.sendKey(0xffe3, "ControlLeft", true); rfb.sendKey(0x0076, "KeyV", true); rfb.sendKey(0x0076, "KeyV", false); rfb.sendKey(0xffe3, "ControlLeft", false); } catch (_) {} });
window.addEventListener("message", (event) => {
if (!event.data || event.data.type !== "lazyboy-host-clipboard") return;
pasteIntoDesktop(String(event.data.text || ""));
}); });
</script> </script>
</head> </head>

View File

@ -572,16 +572,14 @@ pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(
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(|| bound.gui_block.unwrap_or_else(|| "screen unavailable".into()))?;
let active = state.db.active_run(bot_id).await.map_err(|error| error.to_string())?; let active = state.db.active_run(bot_id).await.map_err(|error| error.to_string())?;
let run_status = active let run_status = active.as_ref().and_then(|(_, status)| parse_run_status(status));
.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,
run_status, run_status,
Utc::now(), Utc::now(),
) { ) {
return Err("Stop the bot first".into()); return Err("Stop the task first".into());
} }
let lease_id = Uuid::new_v4().to_string(); let lease_id = Uuid::new_v4().to_string();
let expires = Utc::now() + TimeDelta::minutes(15); let expires = Utc::now() + TimeDelta::minutes(15);
@ -639,6 +637,15 @@ pub async fn release(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())?;
// Releasing human control resumes work that was paused by either the bot or a forced takeover.
sqlx::query(
"UPDATE runs SET status = 'queued', updated_at = now()
WHERE bot_id = $1 AND status = 'waiting_takeover'",
)
.bind(bot_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
Ok(()) Ok(())
} }
@ -785,15 +792,19 @@ pub async fn current_status(state: &AppState, actor: &Actor, bot_id: &str) -> Re
.get_screen(&computer.id, bot_id) .get_screen(&computer.id, bot_id)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let mut status = status_from(bot_id, &computer, screen.as_ref(), None); let run_status = state
status.takeover_requested = state
.db .db
.active_run(bot_id) .active_run(bot_id)
.await .await
.ok() .ok()
.flatten() .flatten()
.and_then(|(_, run_status)| parse_run_status(&run_status)) .and_then(|(_, run_status)| parse_run_status(&run_status));
== Some(lazyboy_contracts::RunStatus::WaitingTakeover); let waiting_for_takeover = run_status == Some(lazyboy_contracts::RunStatus::WaitingTakeover);
let busy_bot_name = run_status
.filter(|status| status.is_active() && *status != lazyboy_contracts::RunStatus::WaitingTakeover)
.map(|_| bot.name);
let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name);
status.takeover_requested = waiting_for_takeover;
Ok(status) Ok(status)
} }

View File

@ -70,6 +70,14 @@ pub struct BotRow {
pub name: String, pub name: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub avatar_color: String,
pub avatar_shape: String,
pub tags: Vec<String>,
pub pinned: bool,
pub hidden: bool,
pub group_name: Option<String>,
pub unread_count: i64,
pub last_message_at: Option<DateTime<Utc>>,
pub instructions: String, pub instructions: String,
pub computer_id: Option<String>, pub computer_id: Option<String>,
pub model_provider: Option<String>, pub model_provider: Option<String>,
@ -103,8 +111,13 @@ impl Db {
pub async fn list_bots(&self, actor: &Actor) -> Result<Vec<(BotRow, String, ComputerRow)>, sqlx::Error> { 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 id, space_id, user_id, name, title, description, instructions, computer_id, model_provider, model_id "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
FROM bots WHERE space_id = $1 AND user_id = $2 ORDER BY created_at DESC", b.pinned, b.hidden, b.group_name,
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
WHERE t.bot_id=b.id AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count,
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id) AS last_message_at,
b.instructions, b.computer_id, b.model_provider, b.model_id
FROM bots b WHERE b.space_id = $1 AND b.user_id = $2 ORDER BY b.pinned DESC, b.created_at DESC",
) )
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(&actor.user_id) .bind(&actor.user_id)
@ -127,8 +140,13 @@ impl Db {
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 id, space_id, user_id, name, title, description, instructions, computer_id, model_provider, model_id "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
FROM bots WHERE id = $1 AND space_id = $2 AND user_id = $3", b.pinned, b.hidden, b.group_name,
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
WHERE t.bot_id=b.id AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count,
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id) AS last_message_at,
b.instructions, b.computer_id, b.model_provider, b.model_id
FROM bots b WHERE b.id = $1 AND b.space_id = $2 AND b.user_id = $3",
) )
.bind(bot_id) .bind(bot_id)
.bind(&actor.space_id) .bind(&actor.space_id)
@ -170,9 +188,15 @@ impl Db {
model_id: Option<&str>, model_id: Option<&str>,
) -> Result<Bot, sqlx::Error> { ) -> Result<Bot, sqlx::Error> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let team = ensure_computer(&mut tx, actor, ComputerMode::Team, None).await?;
let bot_id = Uuid::new_v4().to_string(); let bot_id = Uuid::new_v4().to_string();
let thread_id = Uuid::new_v4().to_string(); let thread_id = Uuid::new_v4().to_string();
let computer = ensure_computer(
&mut tx,
actor,
mode,
(mode == ComputerMode::Dedicated).then_some(bot_id.as_str()),
)
.await?;
sqlx::query( sqlx::query(
"INSERT INTO bots (id, space_id, user_id, name, title, description, instructions, computer_id, model_provider, model_id) "INSERT INTO bots (id, space_id, user_id, name, title, description, instructions, computer_id, model_provider, model_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)",
@ -184,7 +208,7 @@ impl Db {
.bind(title) .bind(title)
.bind(description) .bind(description)
.bind(instructions) .bind(instructions)
.bind(&team.id) .bind(&computer.id)
.bind(model_provider) .bind(model_provider)
.bind(model_id) .bind(model_id)
.execute(&mut *tx) .execute(&mut *tx)
@ -196,18 +220,6 @@ impl Db {
.bind(&actor.user_id) .bind(&actor.user_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let mut computer_id = team.id.clone();
let mut computer_mode = ComputerMode::Team;
if mode == ComputerMode::Dedicated {
let dedicated = ensure_computer(&mut tx, actor, ComputerMode::Dedicated, Some(&bot_id)).await?;
sqlx::query("UPDATE bots SET computer_id = $1 WHERE id = $2")
.bind(&dedicated.id)
.bind(&bot_id)
.execute(&mut *tx)
.await?;
computer_id = dedicated.id;
computer_mode = ComputerMode::Dedicated;
}
tx.commit().await?; tx.commit().await?;
Ok(Bot { Ok(Bot {
id: bot_id, id: bot_id,
@ -215,10 +227,18 @@ impl Db {
name: name.into(), name: name.into(),
title: title.into(), title: title.into(),
description: description.into(), description: description.into(),
avatar_color: "#8B5CF6".into(),
avatar_shape: "blob".into(),
tags: Vec::new(),
pinned: false,
hidden: false,
group_name: None,
unread_count: 0,
last_message_at: None,
instructions: instructions.into(), instructions: instructions.into(),
thread_id, thread_id,
computer_id, computer_id: computer.id,
computer_mode, computer_mode: mode,
model_provider: model_provider.and_then(|value| value.parse().ok()), model_provider: model_provider.and_then(|value| value.parse().ok()),
model_id: model_id.map(str::to_string), model_id: model_id.map(str::to_string),
}) })

View File

@ -1,8 +1,8 @@
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::routing::{any, get, post}; use axum::routing::{any, delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput}; use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput, UpdateBotInput};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -14,7 +14,10 @@ pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.route("/api/health", get(|| async { Json(json!({"ok": true})) })) .route("/api/health", get(|| async { Json(json!({"ok": true})) }))
.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)) .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}/inbox", post(update_inbox))
.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))
@ -45,6 +48,14 @@ async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, Stat
name: bot.name, name: bot.name,
title: bot.title, title: bot.title,
description: bot.description, description: bot.description,
avatar_color: bot.avatar_color,
avatar_shape: bot.avatar_shape,
tags: bot.tags,
pinned: bot.pinned,
hidden: bot.hidden,
group_name: bot.group_name,
unread_count: bot.unread_count,
last_message_at: bot.last_message_at,
instructions: bot.instructions, instructions: bot.instructions,
thread_id, thread_id,
computer_id: computer.id, computer_id: computer.id,
@ -110,6 +121,14 @@ async fn get_bot(State(state): State<AppState>, Path(id): Path<String>) -> Resul
name: bot.name, name: bot.name,
title: bot.title, title: bot.title,
description: bot.description, description: bot.description,
avatar_color: bot.avatar_color,
avatar_shape: bot.avatar_shape,
tags: bot.tags,
pinned: bot.pinned,
hidden: bot.hidden,
group_name: bot.group_name,
unread_count: bot.unread_count,
last_message_at: bot.last_message_at,
instructions: bot.instructions, instructions: bot.instructions,
thread_id, thread_id,
computer_id: computer.id, computer_id: computer.id,
@ -121,6 +140,196 @@ async fn get_bot(State(state): State<AppState>, Path(id): Path<String>) -> Resul
}))) })))
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
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>)> {
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
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",
"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",
"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",
"show" => "UPDATE bots SET hidden=FALSE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"group" => "UPDATE bots SET group_name=$4 WHERE id=$1 AND space_id=$2 AND user_id=$3",
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知操作"})))),
};
let mut statement = sqlx::query(query).bind(&id).bind(&actor.space_id).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})))
}
async fn update_bot(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<UpdateBotInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let name = input.name.trim();
let color_ok = input.avatar_color.len() == 7
&& input.avatar_color.starts_with('#')
&& input.avatar_color[1..].bytes().all(|c| c.is_ascii_hexdigit());
let shape_ok = matches!(input.avatar_shape.as_str(),
"blob" | "round" | "diamond" | "squircle" | "capsule" |
"triangle" | "hexagon" | "cloud" | "drop" |
"organic-4" | "organic-5" | "organic-6" | "organic-7" |
"organic-8" | "organic-9" | "organic-10" | "organic-11"
);
if name.is_empty() || name.chars().count() > 80 || !color_ok || !shape_ok {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"設定格式不正確"}))));
}
let tags: Vec<String> = input.tags.into_iter().map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty()).take(6).collect();
let result = sqlx::query(
"UPDATE bots SET name=$1,title=$2,description=$3,avatar_color=$4,avatar_shape=$5,tags=$6,updated_at=now()
WHERE id=$7 AND space_id=$8 AND user_id=$9",
)
.bind(name).bind(input.title.trim()).bind(input.description.trim())
.bind(input.avatar_color.to_uppercase()).bind(input.avatar_shape).bind(tags)
.bind(&id).bind(&actor.space_id).bind(&actor.user_id)
.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})))
}
async fn delete_bot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
let bot = state
.db
.get_bot(&actor, &id)
.await
.map_err(internal_error)?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({ "message": "bot not found" }))))?;
let computer = match bot.computer_id.as_deref() {
Some(computer_id) => state.db.get_computer(computer_id).await.map_err(internal_error)?,
None => None,
};
// 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_ref) = computer::computer_ref(computer) {
state
.sandbox
.destroy(&computer_ref, &computer::adapter_context(&actor, &id, "delete-bot"))
.await
.map_err(|error| bad_gateway(error.to_string()))?;
}
}
let mut tx = state.pool().begin().await.map_err(internal_error)?;
sqlx::query("DELETE FROM computer_screens WHERE bot_id = $1")
.bind(&id)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
sqlx::query("DELETE FROM computer_profile_locks WHERE bot_id = $1")
.bind(&id)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
sqlx::query("DELETE FROM computer_execution_leases WHERE bot_id = $1")
.bind(&id)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
sqlx::query("DELETE FROM bots WHERE id = $1 AND space_id = $2 AND user_id = $3")
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
if let Some(computer) = computer.as_ref().filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) {
sqlx::query("DELETE FROM computers WHERE id = $1")
.bind(&computer.id)
.execute(&mut *tx)
.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) {
remove_home(&state, &computer.home_key).await?;
}
Ok(Json(json!({ "ok": true })))
}
async fn delete_environment(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
if id != actor.space_id {
return Err((StatusCode::NOT_FOUND, Json(json!({ "message": "environment not found" }))));
}
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,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode FROM computers WHERE space_id = $1 AND user_id = $2",
)
.bind(&id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(internal_error)?;
for row in &computers {
if let Some(computer_ref) = computer::computer_ref(row) {
state
.sandbox
.destroy(&computer_ref, &computer::adapter_context(&actor, "environment", "delete-environment"))
.await
.map_err(|error| bad_gateway(error.to_string()))?;
}
}
let deleted = sqlx::query("DELETE FROM spaces WHERE id = $1 AND user_id = $2")
.bind(&id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(internal_error)?;
if deleted.rows_affected() != 1 {
return Err((StatusCode::NOT_FOUND, Json(json!({ "message": "environment not found" }))));
}
for row in computers {
remove_home(&state, &row.home_key).await?;
}
Ok(Json(json!({ "ok": true })))
}
fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
tracing::error!("delete: {error}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "message": "internal error" })))
}
fn bad_gateway(message: String) -> (StatusCode, Json<Value>) {
tracing::error!("delete sandbox: {message}");
(StatusCode::BAD_GATEWAY, Json(json!({ "message": message })))
}
async fn remove_home(state: &AppState, home_key: &str) -> Result<(), (StatusCode, Json<Value>)> {
let path = computer::home_path(&state.data_dir, home_key);
match tokio::fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(internal_error(error)),
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct SendBody { struct SendBody {
text: String, text: String,
@ -175,6 +384,43 @@ async fn list_messages(
.collect::<Vec<_>>()))) .collect::<Vec<_>>())))
} }
async fn stop_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state
.db
.get_bot(&actor, &id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let run_ids: Vec<String> = sqlx::query_scalar(
"UPDATE runs SET status = 'cancelled', completed_at = now(), updated_at = now()
WHERE bot_id = $1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
RETURNING id",
)
.bind(&id)
.fetch_all(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for run_id in &run_ids {
computer::release_screen_execution(&state, run_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
sqlx::query(
"UPDATE computers SET execution_bot_id = NULL, execution_run_id = NULL,
execution_lease_expires_at = NULL, updated_at = now()
WHERE execution_bot_id = $1",
)
.bind(&id)
.execute(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({ "ok": true })))
}
async fn computer_status( async fn computer_status(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -243,13 +489,6 @@ async fn screen_url(
let Some(computer_ref) = computer::computer_ref(&computer) else { let Some(computer_ref) = computer::computer_ref(&computer) else {
return Ok(Json(json!({ "url": null }))); return Ok(Json(json!({ "url": null })));
}; };
let waiting = state
.db
.active_run(&id)
.await
.ok()
.flatten()
.and_then(|(_, status)| crate::db::parse_run_status(&status));
let screen = match computer::ensure_bot_screen(&state, &actor, &id, &computer, None).await { let screen = match computer::ensure_bot_screen(&state, &actor, &id, &computer, None).await {
Ok(bound) => bound.row, Ok(bound) => bound.row,
Err(error) => { Err(error) => {
@ -257,28 +496,19 @@ async fn screen_url(
state.db.get_screen(&computer.id, &id).await.ok().flatten() state.db.get_screen(&computer.id, &id).await.ok().flatten()
} }
}; };
let bot_driving = screen // Every bot has its own screen slot. The user may interact with that screen directly;
.as_ref() // execution leases still serialize agent-side GUI actions for the same screen.
.and_then(|row| row.execution_run_id.as_ref())
.is_some()
&& screen
.as_ref()
.and_then(|row| row.execution_lease_expires_at)
.is_some_and(|expires| expires > chrono::Utc::now())
&& waiting != Some(lazyboy_contracts::RunStatus::WaitingTakeover);
// Idle screens are clickable. View-only only while this bot is driving the GUI.
let interactive = !bot_driving;
let _ = state let _ = state
.sandbox .sandbox
.connect_screen( .connect_screen(
&computer_ref, &computer_ref,
interactive, true,
&computer::adapter_context_for(&actor, &id, "screen", screen.as_ref(), None), &computer::adapter_context_for(&actor, &id, "screen", screen.as_ref(), None),
) )
.await .await
.map_err(|_| StatusCode::BAD_GATEWAY)?; .map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(Json(json!({ Ok(Json(json!({
"url": format!("/view/{id}/vnc.html?view_only={}", if interactive { "false" } else { "true" }) "url": format!("/view/{id}/vnc.html?view_only=false")
}))) })))
} }
@ -291,9 +521,6 @@ async fn takeover(
.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) if error.contains("Stop the bot") => {
Err((StatusCode::CONFLICT, Json(json!({ "message": error }))))
}
Err(error) => Err((StatusCode::BAD_REQUEST, Json(json!({ "message": error })))), Err(error) => Err((StatusCode::BAD_REQUEST, Json(json!({ "message": error })))),
} }
} }

View File

@ -240,9 +240,9 @@ async fn execute_run(
.messages(history.clone()) .messages(history.clone())
.tools(defs.clone()) .tools(defs.clone())
.build(); .build();
let response = model let response = tokio::time::timeout(Duration::from_secs(120), model.completion(request))
.completion(request)
.await .await
.map_err(|_| "AI 回應逾時120 秒)".to_string())?
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let content: Vec<AssistantContent> = response.choice.into_iter().collect(); let content: Vec<AssistantContent> = response.choice.into_iter().collect();
let assistant = Message::Assistant { let assistant = Message::Assistant {
@ -268,12 +268,32 @@ async fn execute_run(
let mut screen: Option<Vec<u8>> = None; let mut screen: Option<Vec<u8>> = None;
let mut used_desktop = false; let mut used_desktop = false;
for call in calls { for call in calls {
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())?;
if status.as_deref() == Some("cancelled") {
return Ok(());
}
let name = call.function.name.clone(); let name = call.function.name.clone();
used_desktop |= matches!( used_desktop |= matches!(
name.as_str(), name.as_str(),
"computer_observe" | "computer_act" | "open_path" | "launch_app" "computer_observe" | "computer_act" | "open_path" | "launch_app"
); );
let outcome = dispatch(&ctx, &name, &call.function.arguments).await; let outcome = match tokio::time::timeout(
Duration::from_secs(90),
dispatch(&ctx, &name, &call.function.arguments),
)
.await
{
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 the latest
// 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 {
@ -304,6 +324,14 @@ async fn execute_run(
pending = Message::User { content: results }; pending = Message::User { content: results };
} }
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())?;
if status.as_deref() == Some("cancelled") {
return Ok(());
}
append_bot_message(state, thread_id, run_id, &final_text).await?; append_bot_message(state, thread_id, run_id, &final_text).await?;
sqlx::query( sqlx::query(
"UPDATE runs SET status = 'completed', completed_at = now(), updated_at = now() WHERE id = $1", "UPDATE runs SET status = 'completed', completed_at = now(), updated_at = now() WHERE id = $1",

View File

@ -110,7 +110,7 @@ async fn upstream_port(state: &AppState, bot_id: &str, ensure: bool) -> Result<u
.sandbox .sandbox
.connect_screen( .connect_screen(
&computer_ref, &computer_ref,
computer::user_has_screen_control(&computer, screen.as_ref(), bot_id), true,
&computer::adapter_context_for(&actor, bot_id, "view", screen.as_ref(), None), &computer::adapter_context_for(&actor, bot_id, "view", screen.as_ref(), None),
) )
.await .await
@ -218,4 +218,3 @@ async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
} }
} }

View File

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use crate::{ComputerMode, ModelProvider}; use crate::{ComputerMode, ModelProvider};
@ -30,6 +31,14 @@ pub struct Bot {
pub name: String, pub name: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub avatar_color: String,
pub avatar_shape: String,
pub tags: Vec<String>,
pub pinned: bool,
pub hidden: bool,
pub group_name: Option<String>,
pub unread_count: i64,
pub last_message_at: Option<DateTime<Utc>>,
pub instructions: String, pub instructions: String,
pub thread_id: String, pub thread_id: String,
pub computer_id: String, pub computer_id: String,
@ -37,3 +46,17 @@ pub struct Bot {
pub model_provider: Option<ModelProvider>, pub model_provider: Option<ModelProvider>,
pub model_id: Option<String>, pub model_id: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateBotInput {
pub name: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
pub avatar_color: String,
pub avatar_shape: String,
#[serde(default)]
pub tags: Vec<String>,
}

View File

@ -1,14 +1,23 @@
FROM node:22-bookworm-slim AS web
WORKDIR /src/apps/web
COPY apps/web/package.json apps/web/package-lock.json ./
RUN npm ci
COPY apps/web/index.html apps/web/tsconfig.json apps/web/vite.config.ts ./
COPY apps/web/src src
RUN npm run build
FROM rust:1-bookworm AS build FROM rust:1-bookworm AS build
WORKDIR /src WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates crates COPY crates crates
COPY migrations migrations COPY migrations migrations
COPY apps/web apps/web
RUN cargo build --release -p lazyboy-api RUN cargo build --release -p lazyboy-api
FROM debian:bookworm-slim FROM debian:bookworm-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 && rm -rf /var/lib/apt/lists/*
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 apps/web /web COPY --from=web /src/apps/web/dist /web
ENV LAZYBOY_WEB_DIR=/web ENV LAZYBOY_WEB_DIR=/web
EXPOSE 3100 EXPOSE 3100
CMD ["lazyboy-api"] CMD ["lazyboy-api"]

View File

@ -0,0 +1,3 @@
ALTER TABLE bots ADD COLUMN avatar_color TEXT NOT NULL DEFAULT '#8B5CF6';
ALTER TABLE bots ADD COLUMN avatar_shape TEXT NOT NULL DEFAULT 'blob';
ALTER TABLE bots ADD COLUMN tags TEXT[] NOT NULL DEFAULT '{}';

View File

@ -0,0 +1,4 @@
ALTER TABLE bots ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE bots ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE bots ADD COLUMN group_name TEXT;
ALTER TABLE bots ADD COLUMN last_read_at TIMESTAMPTZ NOT NULL DEFAULT now();