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>}
|