lazyBoy/apps/web/src/App.tsx

113 lines
31 KiB
TypeScript
Raw Normal View History

2026-09-03 16:16:34 +00:00
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
2026-09-03 23:43:37 +00:00
import { ArrowUp, Bot as BotIcon, Brain, ChevronDown, Clipboard, ClipboardPaste, Computer, Ellipsis, Eye, EyeOff, FolderPlus, LoaderCircle, LogOut, Mail, Menu, Pin, Plus, Search, Settings, Square, Trash2, Users, X } from "lucide-react";
import { api, ApiError } from "./api";
2026-09-03 16:16:34 +00:00
import { t } from "./i18n";
import blobshape from "blobshape";
2026-09-03 23:43:37 +00:00
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, MemoryItem, Message, Session } from "./types";
2026-09-03 16:16:34 +00:00
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);
2026-09-03 23:43:37 +00:00
const [sessions,setSessions]=useState<Session[]>([]); const [activeSessionId,setActiveSessionId]=useState<string|null>(null);
2026-09-03 16:16:34 +00:00
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("");
2026-09-03 23:43:37 +00:00
const [createOpen,setCreateOpen]=useState(false); const [createMenuOpen,setCreateMenuOpen]=useState(false); const [groupOpen,setGroupOpen]=useState(false); const [settingsOpen,setSettingsOpen]=useState(false); const [memoryOpen,setMemoryOpen]=useState(false); const [deleteOpen,setDeleteOpen]=useState(false); const [computerOpen,setComputerOpen]=useState(false);
2026-09-03 16:16:34 +00:00
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);
2026-09-03 23:43:37 +00:00
const [authRequired,setAuthRequired]=useState(false);
2026-09-03 16:16:34 +00:00
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)},[]);
2026-09-03 23:43:37 +00:00
const loadSessions=useCallback(async()=>{if(!activeId){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(`/api/bots/${activeId}/sessions`);setSessions(next);setActiveSessionId(id=>id&&next.some(session=>session.id===id)?id:next[0]?.id||null)},[activeId]);
const refresh=useCallback(async()=>{if(!activeId||!activeSessionId)return;const [nextMessages,nextComputer,screen]=await Promise.all([api<Message[]>(`/api/sessions/${activeSessionId}/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,activeSessionId]);
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]);
useEffect(()=>{setMessages([]);setActiveSessionId(null);loadSessions().catch(e=>setError(e.message))},[loadSessions]);
useEffect(()=>{if(!activeId||!activeSessionId){setMessages([]);if(!activeId)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,activeSessionId,refresh]);
2026-09-03 16:16:34 +00:00
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)});
2026-09-03 23:43:37 +00:00
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!activeId)return;void action(()=>api(`/api/computer/${activeId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[activeId]);
2026-09-03 16:16:34 +00:00
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)}}
2026-09-03 23:43:37 +00:00
async function send(event:FormEvent){event.preventDefault();const text=draft.trim();if(!active||!activeSessionId||!text)return;setDraft("");await action(()=>api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:crypto.randomUUID()})}))}
async function createSession(){if(!active)return;await action(async()=>{const session=await api<Session>(`/api/bots/${active.id}/sessions`,{method:"POST",body:JSON.stringify({title:`對話 ${sessions.length+1}`})});await loadSessions();setActiveSessionId(session.id);setMessages([])})}
2026-09-03 16:16:34 +00:00
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()}
2026-09-03 23:43:37 +00:00
function openBot(bot:Bot){setActiveSessionId(null);setActiveId(bot.id);setMobileNav(false);if(bot.unreadCount>0)void inbox(bot,"read")}
2026-09-03 16:16:34 +00:00
const frame=screenUrl?<iframe className="desktop-frame" src={screenUrl} title="Agent computer" allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
2026-09-03 23:43:37 +00:00
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:"登入失敗")}}}/>;
2026-09-03 16:16:34 +00:00
return <div className="app-shell">
<aside className={`sidebar ${mobileNav?"open":""}`}>
2026-09-03 23:43:37 +00:00
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap"><button className="icon-button" onClick={()=>setCreateMenuOpen(v=>!v)} aria-label="新增"><Plus/></button>{createMenuOpen&&<div className="create-menu"><button onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/></button><button onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/></button></div>}</div></div>
2026-09-03 16:16:34 +00:00
<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>
2026-09-03 23:43:37 +00:00
<div className="sidebar-bottom"><button className="account" title="目前工作區"><Avatar name="L"/><span>Local workspace</span><ChevronDown/></button><button className="icon-button logout-button" title="登出" onClick={async()=>{await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setActiveId(null);setAuthRequired(true)}}><LogOut/></button></div>
2026-09-03 16:16:34 +00:00
</aside>
<main className="chat-panel">
2026-09-03 23:43:37 +00:00
<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><div className="session-picker"><select aria-label="選擇對話" value={activeSessionId||""} onChange={event=>setActiveSessionId(event.target.value)}>{sessions.map(session=><option value={session.id} key={session.id}>{session.title}</option>)}</select><button className="icon-button" onClick={createSession} title="新增對話" disabled={busy}><Plus/></button></div><span className="grow"/><button className="icon-button" onClick={()=>setMemoryOpen(true)} title="Agent 記憶"><Brain/></button><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>
2026-09-03 16:16:34 +00:00
<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>}
2026-09-03 23:43:37 +00:00
<form className="composer" onSubmit={send}><button type="button" className="composer-plus" disabled title="附件功能尚未開放" aria-label="附件功能尚未開放"><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={activeSessionId&&active?`傳訊息給 ${active.name}`:"先選擇對話"} disabled={!activeSessionId}/>{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={!activeSessionId||!draft.trim()||busy}><ArrowUp/></button>}</form>
2026-09-03 16:16:34 +00:00
</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>
2026-09-03 23:43:37 +00:00
{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">{computer.controlHolder==="user"?"你正在控制":computer.busyBotName?"AI 操作中(唯讀)":"唯讀"}</span></div><div><ControlButtons computer={computer} busy={busy} action={action} active={active}/><button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title="貼上剪貼簿"><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!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>}
2026-09-03 16:16:34 +00:00
{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()}}/>}
2026-09-03 23:43:37 +00:00
{memoryOpen&&active&&<MemoryDialog bot={active} close={()=>setMemoryOpen(false)} changed={loadBots}/>}
2026-09-03 16:16:34 +00:00
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
2026-09-03 23:43:37 +00:00
{groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async()=>{setGroupOpen(false);await loadBots()}}/>}
2026-09-03 16:16:34 +00:00
{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>}
2026-09-03 23:43:37 +00:00
function MemoryDialog({bot,close,changed}:{bot:Bot;close:()=>void;changed:()=>Promise<void>}){
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
const load=useCallback(()=>api<MemoryItem[]>(`/api/bots/${bot.id}/memories`).then(setItems),[bot.id]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:"載入失敗"))},[load]);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:"操作失敗")}finally{setBusy(false)}}
async function toggle(value:boolean){setEnabled(value);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:bot.name,title:bot.title,description:bot.description,avatarColor:bot.avatarColor,avatarShape:bot.avatarShape,tags:bot.tags,memoryEnabled:value})});await changed()}catch(e){setEnabled(!value);setError(e instanceof Error?e.message:"設定失敗")}}
return <div className="modal-backdrop"><div className="dialog settings-dialog memory-dialog">
<div className="dialog-title"><h2>{bot.name} </h2><button onClick={close}><X/></button></div>
<label><input type="checkbox" checked={enabled} onChange={e=>void toggle(e.target.checked)}/> Agent durable memory</label>
<form onSubmit={e=>{e.preventDefault();const content=draft.trim();if(!content)return;void run(async()=>{await api(`/api/bots/${bot.id}/memories`,{method:"POST",body:JSON.stringify({content,importance:.5})});setDraft("")})}}>
<label><textarea value={draft} onChange={e=>setDraft(e.target.value)} rows={3} placeholder="只儲存明確偏好或事實;密碼與 token 會被拒絕。"/></label>
<button className="primary" disabled={busy||!draft.trim()}></button>
</form>
<div className="memory-list">{items.length===0?<p></p>:items.map(item=><div className="memory-row" key={item.id}><span>{item.content}</span><small> {item.importance.toFixed(2)} · rev {item.revision}</small><div><button className="outline" disabled={busy} onClick={()=>{const content=window.prompt("編輯記憶",item.content);if(content!==null)void run(()=>api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"PATCH",body:JSON.stringify({content,importance:item.importance})}))}}></button><button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"DELETE"}))}></button></div></div>)}</div>
{error&&<div className="error-banner">{error}</div>}
<div className="dialog-actions"><button className="danger" disabled={busy||items.length===0} onClick={()=>{if(window.confirm("清除這個 Agent 的所有記憶?"))void run(()=>api(`/api/bots/${bot.id}/memories`,{method:"DELETE"}))}}></button><button className="outline" onClick={close}></button></div>
</div></div>
}
2026-09-03 16:16:34 +00:00
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>}
2026-09-03 23:43:37 +00:00
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>;if(working)return <button className="outline" disabled={busy} onClick={()=>action(async()=>{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("stopTask")}</button>;if(computer.controlHolder==="user")return <button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}></button>;return <button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}></button>}
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>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}><Clipboard/></button></div>}
2026-09-03 16:16:34 +00:00
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>}
2026-09-03 23:43:37 +00:00
function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:()=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState<string[]>([]);const[busy,setBusy]=useState(false);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();const groupName=name.trim();if(!groupName||selected.length===0)return;setBusy(true);try{await Promise.all(selected.map(id=>api(`/api/bots/${id}/inbox`,{method:"POST",body:JSON.stringify({action:"group",groupName})})));created()}finally{setBusy(false)}}}><div className="dialog-title"><h2></h2><button type="button" onClick={close}><X/></button></div><label><input autoFocus value={name} maxLength={30} onChange={e=>setName(e.target.value)} placeholder="例如:產品研究"/></label><fieldset className="group-picker"><legend></legend>{bots.filter(bot=>!bot.hidden).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}/><span>{bot.name}</span></label>)}</fieldset><div className="dialog-actions"><button type="button" className="outline" onClick={close}></button><button className="primary" disabled={busy||!name.trim()||selected.length===0}>{busy?"建立中…":"建立群組"}</button></div></form></div>}
2026-09-03 16:16:34 +00:00
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>}
2026-09-03 23:43:37 +00:00
function LoginScreen({authenticated}:{authenticated:()=>void}){const[token,setToken]=useState("");const[busy,setBusy]=useState(false);const[error,setError]=useState("");return <main className="login-screen"><form className="dialog compact login-dialog" onSubmit={async e=>{e.preventDefault();if(!token)return;setBusy(true);setError("");try{await api("/api/session",{method:"POST",body:JSON.stringify({token})});authenticated()}catch(err){setError(err instanceof Error?err.message:"登入失敗")}finally{setBusy(false)}}}><Avatar name="L" size={58}/><h1> LazyBoy</h1><p> token</p><label> token<input type="password" autoFocus autoComplete="current-password" value={token} onChange={e=>setToken(e.target.value)}/></label>{error&&<div className="login-error">{error}</div>}<button className="primary" disabled={busy||!token}>{busy?"驗證中…":"登入"}</button></form></main>}