import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; 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"; import { t } from "./i18n"; import blobshape from "blobshape"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, MemoryItem, Message, Session } 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 {path&&}} 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([]); const [activeId,setActiveId]=useState(null); const [sessions,setSessions]=useState([]); const [activeSessionId,setActiveSessionId]=useState(null); const [messages,setMessages]=useState([]); const [computer,setComputer]=useState(blankComputer); const [screenUrl,setScreenUrl]=useState(null); const [draft,setDraft]=useState(""); const [query,setQuery]=useState(""); 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); const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState(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 [authRequired,setAuthRequired]=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();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("/api/bots");setBots(next);setActiveId(id=>id&&next.some(b=>b.id===id)?id:next[0]?.id||null)},[]); const loadSessions=useCallback(async()=>{if(!activeId){setSessions([]);setActiveSessionId(null);return}const next=await api(`/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(`/api/sessions/${activeSessionId}/messages`),api(`/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]); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)}); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!activeId)return;void action(()=>api(`/api/computer/${activeId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[activeId]); useEffect(()=>{const close=()=>setContext(null);window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); async function action(work:()=>Promise){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||!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(`/api/bots/${active.id}/sessions`,{method:"POST",body:JSON.stringify({title:`對話 ${sessions.length+1}`})});await loadSessions();setActiveSessionId(session.id);setMessages([])})} async function pasteClipboard(){try{const text=await navigator.clipboard.readText();document.querySelectorAll(".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){setActiveSessionId(null);setActiveId(bot.id);setMobileNav(false);if(bot.unreadCount>0)void inbox(bot,"read")} const frame=screenUrl?