import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons"; import UseAnimations from "./use-animations"; import loading from "react-useanimations/lib/loading"; import arrowUp from "react-useanimations/lib/arrowUp"; import bookmark from "react-useanimations/lib/bookmark"; import copy from "react-useanimations/lib/copy"; import folder from "react-useanimations/lib/folder"; import mail from "react-useanimations/lib/mail"; import menu from "react-useanimations/lib/menu"; import plusToX from "react-useanimations/lib/plusToX"; import settings from "react-useanimations/lib/settings"; import trash2 from "react-useanimations/lib/trash2"; import visibility from "react-useanimations/lib/visibility"; import visibility2 from "react-useanimations/lib/visibility2"; import searchToX from "react-useanimations/lib/searchToX"; import { api, ApiError } from "./api"; import { createCoalescer, subscribeToSession } from "./live"; import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; import { ChatMarkdown, CopyMessageButton } from "./markdown"; import { RunProbe, errorActions, errorTitle } from "./run-monitor"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule"; import { CallOverlay, PhoneIcon } from "./call"; import { VoiceSettingsDialog } from "./voice-settings"; const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",sharedInput:true,controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false}; const SESSION_STORE="lazyboy.sessionByBot"; const PANE_STORE="lazyboy.rightPane"; const WORKSPACE_STORE="lazyboy.workspace"; // Refresh policy: the session's event stream decides when the transcript is // stale, so the timer is only a backstop - it runs slowly while the stream is // up and speeds back up if the stream drops. One turn can move several events, // which is why they settle into a single refresh instead of one fetch each. // The backstop is also the only freshness guarantee for the computer banner: // computers have no events of their own, so this interval is how long a state // change nobody sent a message for (idle park, a crash, another user taking // control) can stay invisible. Four seconds is still lighter than the pre-push // tick, which paid for the whole transcript plus a heartbeat every two. const EVENT_SETTLE_MS=120; const LIVE_POLL_MS=4000; const FALLBACK_POLL_MS=2000; // A heartbeat buys a 15 minute control lease and keeps the 10 minute idle // cutoff from parking the computer, so a minute between beats is a wide margin. const HEARTBEAT_MS=60000; type RightPart="computer"|"memory"|"settings"|"plugins"|"accounts"; type AccountDialog="phone"|"settings"|"model"|"voice"|"about"|"help"|"feedback"|null; function readSessionStore():Record{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record:{}}catch{return {}}} function writeSessionStore(botId:string,sessionId:string){const store=readSessionStore();store[botId]=sessionId;localStorage.setItem(SESSION_STORE,JSON.stringify(store))} function readPaneStore():{collapsed:boolean;part:RightPart}{try{const raw=localStorage.getItem(PANE_STORE);if(!raw)return{collapsed:false,part:"computer"};const value=JSON.parse(raw) as {collapsed?:boolean;part?:string};return{collapsed:Boolean(value.collapsed),part:value.part==="memory"||value.part==="settings"||value.part==="plugins"||value.part==="accounts"?value.part:"computer"}}catch{return{collapsed:false,part:"computer"}}} type WorkspacePrefs={name:string;showHidden:boolean}; function isDefaultWorkspaceName(name?:string|null){return !name||name==="Local workspace"||name==="本機工作區"} function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:isDefaultWorkspaceName(name)?t("localWorkspace"):name||t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}} function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return } 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 isTransitionStep(step?:string|null){return /電腦啟動中|喚醒中|換手中/.test(step||"")} function localizeStepDetail(detail:string){ if(detail==="電腦啟動中"||detail==="電腦啟動中…")return t("hudBooting"); if(detail==="喚醒中"||detail==="喚醒中…")return t("hudWaking"); if(detail==="換手中"||detail==="換手中…")return t("hudHandoff"); if(detail==="思考中")return t("stepThinking"); if(detail==="看畫面")return t("stepWatchingScreen"); if(detail==="重新確認畫面")return t("stepRecheckingScreen"); if(detail==="填入已存帳號")return t("stepFillingLogin"); if(detail==="列出已存帳號")return t("stepListingAccounts"); if(detail==="列出排程")return t("stepListingSchedules"); if(detail==="取消排程")return t("stepCancelingSchedule"); if(detail.startsWith("讀取技能 "))return t("stepUsingSkill",{name:detail.slice("讀取技能 ".length)}); if(detail.startsWith("排程 "))return t("stepScheduling",{name:detail.slice("排程 ".length)}); return detail; } function localizeStep(step?:string|null){ if(!step)return ""; const cut=step.indexOf(": "); if(cut>=0){ const head=step.slice(0,cut); const tail=localizeStepDetail(step.slice(cut+2)); return `${head}: ${tail}`; } return localizeStepDetail(step); } function localizeError(message:string){ if(message==="示範進行中:先按「完成示範」或「取消」,再送訊息。")return t("teachInProgress"); if(message==="AI 回應逾時(150 秒)")return t("aiTimeout"); if(message==="run is not retryable")return t("errorRetryFailed"); if(message==="Stop the task first")return t("takeoverBusy"); return message; } function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean){ const step=computer.busyStep||""; if(computer.state==="booting"||step==="電腦啟動中")return t("hudBooting"); if(computer.state==="suspended"||step==="喚醒中")return t("hudWaking"); if(handingOff||step==="換手中")return t("hudHandoff"); if(connecting)return t("hudConnecting"); return null; } function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();const loc=dateLocale();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat(loc,{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(loc,{weekday:"long"}).format(date);return new Intl.DateTimeFormat(loc,{month:"numeric",day:"numeric"}).format(date)} const ATTACH_MAX=4; const ATTACH_MAX_BYTES=10*1024*1024; const ATTACH_ACCEPT=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.md,.csv,.json,.html,.htm,.xml,.docx,.xlsx,.pptx,image/*,text/*,application/pdf"; type PendingFile={id:string;file:File;preview:string|null}; function attachAllowed(file:File){const mime=(file.type||"").toLowerCase();if(mime.startsWith("image/")||mime.startsWith("text/")||mime==="application/pdf"||mime==="application/json"||mime==="application/xml")return true;return /\.(png|jpe?g|gif|webp|pdf|txt|md|csv|json|html?|xml|docx|xlsx|pptx)$/i.test(file.name)} function readAsBase64(file:File){return new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>{const value=String(reader.result||"");const comma=value.indexOf(",");resolve(comma>=0?value.slice(comma+1):value)};reader.onerror=()=>reject(reader.error||new Error("read failed"));reader.readAsDataURL(file)})} function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`} function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()} function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})} type MessageChip={kind:string;site?:string;why?:string;name?:string;human?:string;cron?:string;reason?:string;turns?:number;limit?:number;code?:string;retryable?:boolean;runId?:string;turn?:number;step?:string|null}; function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as MessageChip[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as MessageChip;if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun"||value.kind==="resume"||value.kind==="error")return [value];return []})} function resumeTitle(reason?:string){return reason==="budget_exhausted"?t("resumeBudget"):reason==="loop_detected"?t("resumeLoop"):t("resumeMidTask")} function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===`Attached ${file.name}`||text===t("attachedFile",{name:file.name}))} function isAutoScheduleCaption(body:string,chips:{kind?:string}[]){if(!chips.some(chip=>chip.kind==="schedule"||chip.kind==="scheduleRun"))return false;const text=body.trim();return /^\[排程(試跑)?\]/.test(text)||/^\[Schedule( test)?\]/i.test(text)} function FileCard({file,preview,onRemove}:{file:{name:string;size?:number};preview?:string|null;onRemove?:()=>void}){const ext=fileExt(file.name);return
{preview?:}
{file.name}{typeof file.size==="number"?formatBytes(file.size):ext}
{onRemove&&}
} function clientNonce(){ const webCrypto=globalThis.crypto; if(webCrypto&&typeof webCrypto.randomUUID==="function")return webCrypto.randomUUID(); const bytes=new Uint8Array(16); if(webCrypto&&typeof webCrypto.getRandomValues==="function")webCrypto.getRandomValues(bytes); else for(let i=0;ib.toString(16).padStart(2,"0")).join(""); return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`; } export function App(){ const locale=useLocale(); const [bots,setBots]=useState([]); const [rooms,setRooms]=useState([]); const [activeId,setActiveId]=useState(null); const [activeRoomId,setActiveRoomId]=useState(null); const [busyMembers,setBusyMembers]=useState([]); 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 [deleteOpen,setDeleteOpen]=useState(false); const [computerOpen,setComputerOpen]=useState(false); const paneStart=readPaneStore(); const [rightCollapsed,setRightCollapsed]=useState(paneStart.collapsed); const [rightPart,setRightPart]=useState(paneStart.part); const [sessionMenuOpen,setSessionMenuOpen]=useState(false); const [clearOpen,setClearOpen]=useState(false); const [sessionToDelete,setSessionToDelete]=useState(null); const [remembered,setRemembered]=useState>({}); const [resumedChips,setResumedChips]=useState>({}); const [retriedRuns,setRetriedRuns]=useState>({}); const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState(null); const [busy,setBusy]=useState(false); useEffect(()=>{ if(!mobileNav)return; const dismiss=(event:KeyboardEvent)=>{if(event.key==="Escape")setMobileNav(false)}; window.addEventListener("keydown",dismiss); return()=>window.removeEventListener("keydown",dismiss); },[mobileNav]); const [desktopClipboard,setDesktopClipboard]=useState(""); const active=bots.find(b=>b.id===activeId)||null; const activeRoom=rooms.find(room=>room.id===activeRoomId)||null; const [clipboardOpen,setClipboardOpen]=useState(false); const [authRequired,setAuthRequired]=useState(false); const workspaceStart=readWorkspace(); const [showHidden,setShowHidden]=useState(workspaceStart.showHidden);const[context,setContext]=useState<{bot:Bot;x:number;y:number}|null>(null); const [roomContext,setRoomContext]=useState<{room:Room;x:number;y:number}|null>(null); const [roomToDelete,setRoomToDelete]=useState(null); const [mcpServers,setMcpServers]=useState([]); const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState(null); const [voiceSettings,setVoiceSettings]=useState(null); const [callOpen,setCallOpen]=useState(false); const [skills,setSkills]=useState([]); const [fileSkills,setFileSkills]=useState([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState(null); const [schedules,setSchedules]=useState([]); const [scheduleDraft,setScheduleDraft]=useState<{name:string;instructions:string;enabled:boolean;preset:CronPreset;id?:string;timezone?:string;threadId?:string|null}|null>(null); const [scheduleError,setScheduleError]=useState(null); const [scheduleSaving,setScheduleSaving]=useState(false); const [runningScheduleId,setRunningScheduleId]=useState(null); const [workspaceName,setWorkspaceName]=useState(workspaceStart.name); const [looks,setLooks]=useState(readAvatarLooks); const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef(null); const attachRef=useRef(null); const desktopFrameRef=useRef(null); const holderRef=useRef(computer.controlHolder); const paneBotRef=useRef(null); const skipHandoffRef=useRef(true); const [desktopReady,setDesktopReady]=useState(false); const [handingOff,setHandingOff]=useState(false); // The holder the server has not agreed to yet, when the current handoff // started, and a guard so a fast double click cannot fight itself over the // mouse. All three exist so the click, not the round trip, owns the answer. const expectedHolderRef=useRef(null); const handoffAtRef=useRef(0); const controlBusyRef=useRef(false); const [controlBusy,setControlBusy]=useState(false); const [veil,setVeil]=useState({label:null,leaving:false}); const [pendingFiles,setPendingFiles]=useState([]); const messageEndRef=useRef(null); const sentHistoryRef=useRef([]); const historyIndexRef=useRef(null); const historyDraftRef=useRef(""); const roomsRef=useRef(rooms); roomsRef.current=rooms; const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]); const filteredRooms=useMemo(()=>{const q=query.trim().toLowerCase();return rooms.filter(room=>!q||room.name.toLowerCase().includes(q)||room.members.some(member=>member.name.toLowerCase().includes(q)))},[rooms,query]); const sections=useMemo(()=>{const map=new Map();for(const bot of filtered){const key=bot.pinned?t("pinned"):bot.groupName||t("agentGroup");map.set(key,[...(map.get(key)||[]),bot])}return [...map.entries()]},[filtered]); const paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId; const paneBot=bots.find(bot=>bot.id===paneBotId)||active; const currentPaneRef=useRef(paneBotId);currentPaneRef.current=paneBotId; const pasteQueueRef=useRef>(Promise.resolve()); const [clipboardStatus,setClipboardStatus]=useState(""); const workingMembers=activeRoom?busyMembers:active&&activeSessionId&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[]; const lastMessageId=messages[messages.length-1]?.id||""; // The bot is parked in waiting_takeover: nothing moves (including queued // messages) until the human releases the screen, so say so loudly. const desktopInteractive=computer.screenAvailable&&!viewOnlyFor(computer.controlHolder,computer.sharedInput); const pausedForUser=computer.takeoverRequested&&workingMembers.length===0&&(!computer.waitingSessionId||computer.waitingSessionId===activeSessionId||Boolean(activeRoom)); // Only the newest assistant reply can still be "continued": an older pause // belongs to a run that has already moved on. const lastAssistantSeq=messages.reduce((max,message)=>message.role==="assistant"?Math.max(max,message.seq??0):max,0); // Teaching by demonstration: one recording at a time per bot, then a draft // playbook the human names and saves before it becomes a real skill. const teaching=skills.find(skill=>skill.status==="recording")||null; const drafting=skills.find(skill=>skill.status==="drafting")||null; const skillDraft=teaching||drafting?null:skills.find(skill=>skill.status==="draft")||null; const savedSkills=skills.filter(skill=>skill.status==="saved"); const skillNeedle=skillQuery.trim().toLowerCase(); const listedSkills=skillNeedle?savedSkills.filter(skill=>skill.name.toLowerCase().includes(skillNeedle)||(skill.playbook.whenToUse||"").toLowerCase().includes(skillNeedle)||skill.goal.toLowerCase().includes(skillNeedle)):savedSkills; const [slashIndex,setSlashIndex]=useState(0); const [slashDismissed,setSlashDismissed]=useState(false); useEffect(()=>{setSlashIndex(0);setSlashDismissed(false)},[draft]); const slashToken=draft.trimStart().split(/\s/,1)[0].slice(1).toLowerCase(); const slashSuggestions=!slashDismissed&&/^\/[^\s]*$/.test(draft.trimStart()) ? [{name:"goal",description:t("goalCommandHint"),kind:t("slashMode")},...fileSkills.map(skill=>({...skill,kind:t("slashFileSkill")}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8) : []; const loadMcp=useCallback(async()=>{setMcpServers(await api("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]); const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api("/api/bots"),api("/api/rooms").catch(()=>[] as Room[])]);setBots(next);setRooms(nextRooms);setActiveRoomId(id=>id&&nextRooms.some(room=>room.id===id)?id:null);setActiveId(id=>id&&next.some(b=>b.id===id)?id:null);await loadMcp()},[loadMcp]); const sessionStoreKey=activeRoomId?`room:${activeRoomId}`:activeId; const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null; const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]); const refresh=useCallback(async()=>{if(!activeSessionId)return;const refreshSeq=++refreshSeqRef.current;let nextBusy:RoomMember[]=[]; let computerBot=activeId; if(activeRoomId){ try{const roomStatus=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);if(refreshSeq!==refreshSeqRef.current)return;nextBusy=roomStatus.busy;computerBot=roomStatus.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{if(refreshSeq!==refreshSeqRef.current)return;computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null} } const messagesJob=api(`/api/sessions/${activeSessionId}/messages`); if(!computerBot){const nextMessages=await messagesJob;if(refreshSeq===refreshSeqRef.current){setBusyMembers(nextBusy);setMessages(nextMessages);setComputer(blankComputer);setSkills([]);setScreenUrl(null)}return} const skillsJob=activeRoomId?Promise.resolve([] as TaughtSkill[]):api(`/api/bots/${computerBot}/skills`).catch(()=>[] as TaughtSkill[]); const status=await api(`/api/computer/${computerBot}/status`);if(refreshSeq!==refreshSeqRef.current)return; const [nextMessages,screen,nextSkills]=await Promise.all([messagesJob,status.state==="running"?api<{url:string|null}>(`/api/computer/${computerBot}/screen`).catch(()=>({url:null})):Promise.resolve({url:null}),skillsJob]); if(refreshSeq!==refreshSeqRef.current)return; setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills); // A missing url is not a dead desktop. While it boots or wakes the mounted // frame keeps its VNC session instead of blacking out and reconnecting. setScreenUrl(current=>status.botId===computerBot?keepScreenUrl(current,screen.url,status.state):null); // Agreement with the server is what ends a handoff. The beat has a floor so // the swap is still visible on a LAN, and the ceiling timer still covers a // reply that never lands. if(expectedHolderRef.current&&status.controlHolder===expectedHolderRef.current){ expectedHolderRef.current=null; const wait=handoffRemaining(handoffAtRef.current,Date.now()); if(wait>0)window.setTimeout(()=>setHandingOff(false),wait);else setHandingOff(false); } },[activeId,activeRoomId,activeSessionId]); useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(localizeError(e.message))})},[loadBots]); useEffect(()=>{if(!voiceSettings?.enabled)setCallOpen(false)},[voiceSettings?.enabled]); useEffect(()=>{api("/api/voice/settings").then(setVoiceSettings).catch(()=>setVoiceSettings(null))},[]); useEffect(()=>{if(authRequired)return;api("/api/file-skills").then(setFileSkills).catch(()=>setFileSkills([]))},[authRequired]); useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]); useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(localizeError(e.message)))},[loadSessions]); useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current="";setPendingFiles(current=>{current.forEach(file=>file.preview&&URL.revokeObjectURL(file.preview));return []})},[activeSessionId]); useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]); useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]); // Live transcript: the event stream says when to look, so a reply appears as // soon as it is written. Polling stays as a backstop that cannot starve the // database, and a hidden tab stops re-reading until it comes back to the // foreground - which is itself a "what happened" moment worth refreshing on. useEffect(()=>{ if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return} setScreenUrl(null); refresh().catch(e=>setError(localizeError(e.message))); const settle=createCoalescer(()=>{if(!document.hidden)refresh().catch(()=>{})},EVENT_SETTLE_MS); let live=false,opened=false,timer=0; const tick=()=>{if(!document.hidden)refresh().catch(()=>{});timer=window.setTimeout(tick,live?LIVE_POLL_MS:FALLBACK_POLL_MS)}; timer=window.setTimeout(tick,FALLBACK_POLL_MS); const heartbeat=window.setInterval(()=>{const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},HEARTBEAT_MS); const feed=subscribeToSession(activeSessionId,()=>settle.kick(),{onStatus:connected=>{live=connected;if(connected&&opened)refresh().catch(()=>{});opened=true}}); const resume=()=>{if(!document.hidden)refresh().catch(()=>{})}; document.addEventListener("visibilitychange",resume); return()=>{window.clearTimeout(timer);window.clearInterval(heartbeat);document.removeEventListener("visibilitychange",resume);feed.close();settle.cancel();refreshSeqRef.current+=1}; },[activeId,activeRoomId,activeSessionId,refresh]); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus(t("clipboardSynced"))).catch(()=>setClipboardStatus(t("clipboardSyncBlocked")))}if(event.data.type==="lazyboy-copy-request"&&desktopInteractive)void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-mobile-key"&&typeof event.data.key==="string"&&/^(?:(?:ctrl|alt)\+)?(?:Return|BackSpace|Tab|Escape|Left|Right|Up|Down|[acvz])$/.test(event.data.key))queueDesktopInput({kind:"key",key:event.data.key});if(event.data.type==="lazyboy-paste-request"&&desktopInteractive)setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)}); useEffect(()=>{setDesktopReady(false)},[screenUrl,paneBotId]); useEffect(()=>{if(skipHandoffRef.current){skipHandoffRef.current=false;holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId;return}if((holderRef.current!==computer.controlHolder||paneBotRef.current!==paneBotId)&&computer.state==="running"&&!computer.sharedInput)setHandingOff(true);holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId},[computer.controlHolder,paneBotId,computer.state,computer.sharedInput]); useEffect(()=>{if(!handingOff)return;handoffAtRef.current=Date.now();const timer=setTimeout(()=>{expectedHolderRef.current=null;setHandingOff(false)},HANDOFF_MS);return()=>clearTimeout(timer)},[handingOff]); useEffect(()=>{const frame=desktopFrameRef.current;if(!frame?.contentWindow||!screenUrl)return;frame.contentWindow.postMessage({type:"lazyboy-view-only",viewOnly:!desktopInteractive},location.origin)},[desktopInteractive,screenUrl,desktopReady]); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;if(computer.sharedInput){pushViewOnly(!desktopInteractive);return}void setControl("user")};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId,computer.sharedInput,desktopInteractive]); useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]); useEffect(()=>{setWorkspaceName(name=>isDefaultWorkspaceName(name)?t("localWorkspace"):name)},[locale]); useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]); useEffect(()=>{if(!sessionStoreKey||!activeSessionId)return;if(!sessions.some(session=>session.id===activeSessionId))return;if(!activeRoomId&&!sessions.some(session=>session.id===activeSessionId&&session.botId===activeId))return;writeSessionStore(sessionStoreKey,activeSessionId)},[sessionStoreKey,activeId,activeRoomId,activeSessionId,sessions]); useEffect(()=>{localStorage.setItem(PANE_STORE,JSON.stringify({collapsed:rightCollapsed,part:rightPart}))},[rightCollapsed,rightPart]); useEffect(()=>{if(!paneBotId){setSchedules([]);return}api(`/api/bots/${paneBotId}/schedules`).then(setSchedules).catch(()=>setSchedules([]))},[paneBotId]); useEffect(()=>{if(computer.takeoverRequested){setRightPart("computer");setRightCollapsed(false)}},[computer.takeoverRequested]); function openPane(part:RightPart){setMobileNav(false);setRightPart(part);setRightCollapsed(false)} async function openLoginScreen(){ const id=paneBot?.id||active?.id;if(!id)return; setRightPart("computer");setRightCollapsed(false);setComputerOpen(true); await action(async()=>{try{await api(`/api/computer/${id}/boot`,{method:"POST",body:"{}"})}catch{/* already up */}}); // Opening the shared screen never pauses a running task. if(!computer.sharedInput)await setControl("user",id); } async function reloadSchedules(){if(!paneBotId)return;setSchedules(await api(`/api/bots/${paneBotId}/schedules`))} async function saveScheduleDraft(){ if(!paneBot||!scheduleDraft)return; setScheduleSaving(true);setScheduleError(null); try{ const body={name:scheduleDraft.name.trim(),cron:cronFromPreset(scheduleDraft.preset),instructions:scheduleDraft.instructions.trim(),timezone:scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei",enabled:scheduleDraft.enabled,threadId:scheduleDraft.id?scheduleDraft.threadId:activeRoomId?undefined:activeSessionId}; if(scheduleDraft.id)await api(`/api/schedules/${scheduleDraft.id}`,{method:"PATCH",body:JSON.stringify(body)}); else await api(`/api/bots/${paneBot.id}/schedules`,{method:"POST",body:JSON.stringify(body)}); setScheduleDraft(null);await reloadSchedules(); }catch(e){setScheduleError(e instanceof Error?localizeError(e.message):t("operationFailed"))} finally{setScheduleSaving(false)} } const sessionBusy=workingMembers.length>0; const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy); const chatName=activeRoom?.name||active?.name||""; async function action(work:()=>Promise){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} async function stopChat(){if(activeSessionId)await api(`/api/sessions/${activeSessionId}/stop`,{method:"POST",body:"{}"});else if(active)await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});} async function startTeaching(goal:string){if(!active)return;setTeachOpen(false);await action(async()=>{await api(`/api/bots/${active.id}/skills/start`,{method:"POST",body:JSON.stringify({goal})});setComputerOpen(true)})} async function stopTeaching(){if(!active)return;await action(()=>api(`/api/bots/${active.id}/skills/stop`,{method:"POST",body:"{}"}));setComputerOpen(false)} async function cancelTeaching(){if(!active)return;await action(()=>api(`/api/bots/${active.id}/skills/cancel`,{method:"POST",body:"{}"}));setComputerOpen(false)} async function saveSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(()=>api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook,save:true})}))} async function testSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(async()=>{await api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook})});await api(`/api/skills/${skill.id}/test`,{method:"POST",body:"{}"})})} async function discardSkill(skill:TaughtSkill){await action(()=>api(`/api/skills/${skill.id}`,{method:"DELETE"}))} async function updateSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(()=>api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook,save:true})}));setEditingSkillId(null)} async function deleteSkill(skill:TaughtSkill){if(!window.confirm(t("deleteSkillConfirm",{name:skill.name||skill.goal})))return;await action(()=>api(`/api/skills/${skill.id}`,{method:"DELETE"}));setEditingSkillId(null)} const editingSkill=editingSkillId?skills.find(skill=>skill.id===editingSkillId)||null:null; function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}${t("runSkillNamed",{name:skill.name})}`);document.querySelector(".composer textarea")?.focus()} async function importSkillFile(file:File){if(!active)return;await action(async()=>{let payload:unknown;try{payload=JSON.parse(await file.text())}catch{throw new Error(t("skillImportInvalid"))}const skill=await api(`/api/bots/${active.id}/skills/import`,{method:"POST",body:JSON.stringify(payload)});setEditingSkillId(skill.id)})} function addPendingFiles(list:FileList|File[]){const incoming=[...list];if(!incoming.length)return;setError(null);setPendingFiles(current=>{const next=[...current];for(const file of incoming){if(next.length>=ATTACH_MAX){setError(t("attachTooMany"));break}if(file.size>ATTACH_MAX_BYTES){setError(t("attachTooLarge"));continue}if(!attachAllowed(file)){setError(t("attachType"));continue}next.push({id:clientNonce(),file,preview:file.type.startsWith("image/")?URL.createObjectURL(file):null})}return next})} function removePendingFile(id:string){setPendingFiles(current=>current.filter(item=>{if(item.id===id&&item.preview)URL.revokeObjectURL(item.preview);return item.id!==id}))} async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();const files=pendingFiles;if((!active&&!activeRoom)||!activeSessionId||(!text&&files.length===0))return;sendingRef.current=true;setDraft("");setPendingFiles([]);historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{try{const attachments=await Promise.all(files.map(async item=>({name:item.file.name,mimeType:item.file.type,content:await readAsBase64(item.file)})));await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce(),attachments})});sentHistoryRef.current.push(text||files[0]?.file.name||"");if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();files.forEach(item=>item.preview&&URL.revokeObjectURL(item.preview));await loadSessions()}catch(error){setPendingFiles(files);throw error}})}finally{sendingRef.current=false}} function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} // A run that stopped to ask keeps its harness state, so the answer is just a // normal message: the backend folds it into the paused run instead of // starting a new task. async function continueRun(messageId:string){if(!activeSessionId||sendingRef.current||busy)return;sendingRef.current=true;const text=t("resumeSent");try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce()})});setResumedChips(current=>({...current,[messageId]:true}));await loadSessions()})}finally{sendingRef.current=false}} async function retryRun(runId?:string){if(!runId||busy)return;await action(()=>api(`/api/runs/${runId}/retry`,{method:"POST",body:"{}"}));setRetriedRuns(current=>({...current,[runId]:true}))} async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})} async function deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?localizeError(e.message):t("rememberFailed"))}} function pasteText(text:string){queueDesktopInput({kind:"clipboard",text})} function queueDesktopInput(input:{kind:"clipboard";text:string}|{kind:"key";key:string}){ const botId=paneBotId; if(!botId||!desktopInteractive)return; pasteQueueRef.current=pasteQueueRef.current.catch(()=>{}).then(async()=>{ if(currentPaneRef.current!==botId)return; await api(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify(input)}); if(input.kind==="clipboard"&¤tPaneRef.current===botId)setClipboardStatus(t("clipboardPasted")); }).catch(()=>setClipboardStatus(t("clipboardPasteFailed"))); } async function copySelection(){ const botId=paneBotId;if(!botId)return; try{const result=await api<{text:string}>(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"copy"})}); if(currentPaneRef.current!==botId)return; setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus(t("clipboardCopiedSelection")); }catch{setClipboardStatus(t("clipboardCopyFailed"));} } async function pasteClipboard(){try{pasteText(await navigator.clipboard.readText())}catch{setClipboardOpen(true)}} async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}} 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){setCallOpen(false);setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)} function openRoom(room:Room){setCallOpen(false);setMobileNav(false);setSessionMenuOpen(false);if(rightPart==="settings")setRightPart("computer");if(room.id===activeRoomId){if(!activeSessionId){const stored=readSessionStore()[`room:${room.id}`];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveId(null);setBusyMembers([]);setActiveRoomId(room.id)} async function deleteRoom(room:Room){setRoomToDelete(null);await action(async()=>{await api(`/api/rooms/${room.id}`,{method:"DELETE"});if(activeRoomId===room.id){setActiveRoomId(null);setActiveSessionId(null);setMessages([]);setBusyMembers([])}await loadBots()})} function openAccount(dialog:AccountDialog){setAccountOpen(false);setAccountDialog(dialog)} async function logout(){setAccountOpen(false);await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setRooms([]);setMcpServers([]);setActiveId(null);setActiveRoomId(null);setAuthRequired(true)} async function changeComputer(operation:"boot"|"restart"|"stop"){ const botId=paneBotId;if(!botId)return; const previous=computer; setDesktopReady(false); // Stopping drops the pixels; starting keeps them. The viewer url never // changes, so the frame mounts straight away and noVNC dials in the moment // X answers, instead of booting only after the reply has already arrived. if(operation==="stop")setScreenUrl(null);else setScreenUrl(current=>current||viewerPath(botId)); setComputer(current=>({...current,state:operation==="stop"?current.state:operation==="boot"&¤t.state==="suspended"?"suspended":"booting"})); try{ const status=await api(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)}); if(currentPaneRef.current!==botId)return; setComputer(status); // Anything but running and the warm frame is a lie: drop it so the panel // shows what is really there instead of a desktop that will never return. if(status.state!=="running")setScreenUrl(null); } catch(error){ const status=await api(`/api/computer/${botId}/status`,{signal:AbortSignal.timeout(5_000)}).catch(()=>previous); if(currentPaneRef.current===botId){setComputer(status);if(status.state!=="running")setScreenUrl(null)} throw error; } } const startBoot=()=>changeComputer("boot"); const stopComputer=()=>changeComputer("stop"); const restartComputer=()=>changeComputer("restart"); /** Tell the viewer, this instant, whether human input counts. The lease is * advisory inside the container, so this is the gate that actually moves the * mouse, and nobody should wait on a round trip to be able to click. */ function pushViewOnly(viewOnly:boolean){desktopFrameRef.current?.contentWindow?.postMessage({type:"lazyboy-view-only",viewOnly},location.origin)} /** Take or release the screen. The button, the badge, the veil and the mouse * all move on the local copy; the server only has to agree with what is * already on screen, and if it refuses the read-back puts the mouse back. */ async function setControl(holder:ComputerStatus["controlHolder"],botId:string|null=paneBotId){ if(!botId||controlBusyRef.current)return; controlBusyRef.current=true;setControlBusy(true); expectedHolderRef.current=holder; setComputer(current=>({...current,controlHolder:holder,takeoverRequested:holder==="user"?false:current.takeoverRequested})); if(!computer.sharedInput)setHandingOff(true); pushViewOnly(viewOnlyFor(holder,computer.sharedInput)); try{await api(`/api/computer/${botId}/${holder==="user"?"takeover":"release"}`,{method:"POST",body:"{}"})} catch(error){setError(localizeError(error instanceof Error?error.message:t("operationFailed")))} finally{ // Read the truth back once: a refused takeover has to give the mouse up // again, and the status answer is who holds it now. await refresh().catch(()=>{});expectedHolderRef.current=null;controlBusyRef.current=false;setControlBusy(false); } } useEffect(() => { const viewport = window.visualViewport; const update = () => { document.documentElement.style.setProperty("--visible-height", `${viewport?.height || window.innerHeight}px`); document.documentElement.style.setProperty("--visible-top", `${viewport?.offsetTop || 0}px`); }; update(); viewport?.addEventListener("resize", update); viewport?.addEventListener("scroll", update); window.addEventListener("resize", update); return () => { viewport?.removeEventListener("resize", update); viewport?.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; }, []); const connecting=computer.state==="running"&&Boolean(screenUrl)&&!desktopReady; const overlayLabel=hudLabel(computer,connecting,handingOff); // The veil fades in with its label and fades back out through the same // mascot, so a fast handoff reads as a finished gesture rather than a frame // that was dropped. Status blips never get to strobe it. useEffect(()=>{setVeil(current=>nextVeil(overlayLabel,current))},[overlayLabel]); useEffect(()=>{if(!veil.leaving)return;const timer=setTimeout(()=>setVeil({label:null,leaving:false}),VEIL_FADE_MS);return()=>clearTimeout(timer)},[veil.leaving]); const frame=screenUrl?