lazyBoy/apps/web/src/App.tsx

593 lines
89 KiB
TypeScript
Raw Normal View History

2026-09-04 09:57:33 +00:00
import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
2026-09-04 14:43:29 +00:00
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Users, X } from "./animated-icons";
2026-09-04 05:41:09 +00:00
import UseAnimations from "react-useanimations";
import loading from "react-useanimations/lib/loading";
import loading2 from "react-useanimations/lib/loading2";
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";
2026-09-03 23:43:37 +00:00
import { api, ApiError } from "./api";
2026-09-04 09:08:56 +00:00
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 { t, type MessageKey } from "./i18n";
2026-09-04 14:43:29 +00:00
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, ModelProviderId, Playbook, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
2026-09-03 16:16:34 +00:00
2026-09-04 14:43:29 +00:00
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
2026-09-04 05:41:09 +00:00
const SESSION_STORE="lazyboy.sessionByBot";
const PANE_STORE="lazyboy.rightPane";
const WORKSPACE_STORE="lazyboy.workspace";
type RightPart="computer"|"memory"|"settings"|"plugins";
2026-09-04 09:08:56 +00:00
type AccountDialog="phone"|"settings"|"model"|"about"|"help"|"feedback"|null;
2026-09-04 05:41:09 +00:00
function readSessionStore():Record<string,string>{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record<string,string>:{}}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:"computer"}}catch{return{collapsed:false,part:"computer"}}}
type WorkspacePrefs={name:string;showHidden:boolean};
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:name&&name!=="Local workspace"?name:t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
2026-09-03 16:16:34 +00:00
2026-09-04 05:41:09 +00:00
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 <span className="workspace-avatar" aria-hidden="true">{initials}</span>}
2026-09-03 16:16:34 +00:00
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)}
2026-09-04 09:08:56 +00:00
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;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256);
bytes[6]=(bytes[6]&0x0f)|0x40;
bytes[8]=(bytes[8]&0x3f)|0x80;
const hex=[...bytes].map(b=>b.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)}`;
}
2026-09-03 16:16:34 +00:00
export function App(){
2026-09-04 05:41:09 +00:00
const [bots,setBots]=useState<Bot[]>([]); const [rooms,setRooms]=useState<Room[]>([]); const [activeId,setActiveId]=useState<string|null>(null); const [activeRoomId,setActiveRoomId]=useState<string|null>(null); const [busyMembers,setBusyMembers]=useState<RoomMember[]>([]);
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-04 05:41:09 +00:00
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<RightPart>(paneStart.part);
const [sessionMenuOpen,setSessionMenuOpen]=useState(false); const [clearOpen,setClearOpen]=useState(false); const [sessionToDelete,setSessionToDelete]=useState<string|null>(null); const [remembered,setRemembered]=useState<Record<string,boolean>>({});
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;
2026-09-04 05:41:09 +00:00
const activeRoom=rooms.find(room=>room.id===activeRoomId)||null;
2026-09-03 16:16:34 +00:00
const [clipboardOpen,setClipboardOpen]=useState(false);
2026-09-03 23:43:37 +00:00
const [authRequired,setAuthRequired]=useState(false);
2026-09-04 05:41:09 +00:00
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<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
2026-09-04 14:43:29 +00:00
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [teachOpen,setTeachOpen]=useState(false);
2026-09-04 05:41:09 +00:00
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
2026-09-04 09:08:56 +00:00
const [looks,setLooks]=useState(readAvatarLooks);
2026-09-04 09:57:33 +00:00
const sendingRef=useRef(false); const refreshSeqRef=useRef(0);
const messageEndRef=useRef<HTMLDivElement|null>(null);
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
2026-09-04 05:41:09 +00:00
const roomsRef=useRef(rooms);
roomsRef.current=rooms;
2026-09-03 16:16:34 +00:00
const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]);
2026-09-04 05:41:09 +00:00
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<string,Bot[]>();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 workingMembers=activeRoom?busyMembers:active&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[];
2026-09-04 09:57:33 +00:00
const lastMessageId=messages[messages.length-1]?.id||"";
2026-09-04 14:43:29 +00:00
// The bot is parked in waiting_takeover: nothing moves (including queued
// messages) until the human releases the screen, so say so loudly.
const pausedForUser=computer.takeoverRequested&&workingMembers.length===0&&(!computer.waitingSessionId||computer.waitingSessionId===activeSessionId||Boolean(activeRoom));
// 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");
2026-09-03 16:16:34 +00:00
2026-09-04 05:41:09 +00:00
const loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]);
const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api<Bot[]>("/api/bots"),api<Room[]>("/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<Session[]>(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]);
2026-09-04 09:57:33 +00:00
const refresh=useCallback(async()=>{if(!activeSessionId)return;const refreshSeq=++refreshSeqRef.current;let nextBusy:RoomMember[]=[];
2026-09-04 05:41:09 +00:00
let computerBot=activeId;
if(activeRoomId){
2026-09-04 09:57:33 +00:00
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<Message[]>(`/api/sessions/${activeSessionId}/messages`);
2026-09-04 14:43:29 +00:00
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<TaughtSkill[]>(`/api/bots/${computerBot}/skills`).catch(()=>[] as TaughtSkill[]);
2026-09-04 09:57:33 +00:00
const status=await api<ComputerStatus>(`/api/computer/${computerBot}/status`);if(refreshSeq!==refreshSeqRef.current)return;
2026-09-04 14:43:29 +00:00
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]);
2026-09-04 09:57:33 +00:00
if(refreshSeq!==refreshSeqRef.current)return;
2026-09-04 14:43:29 +00:00
setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills);setScreenUrl(status.botId===computerBot?screen.url:null)
2026-09-04 09:57:33 +00:00
},[activeId,activeRoomId,activeSessionId]);
2026-09-03 23:43:37 +00:00
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]);
2026-09-04 05:41:09 +00:00
useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]);
useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(e.message))},[loadSessions]);
2026-09-04 09:57:33 +00:00
useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current=""},[activeSessionId]);
2026-09-04 14:43:29 +00:00
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]);
2026-09-04 09:57:33 +00:00
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(e.message));const timer=setInterval(()=>{refresh().catch(()=>{});const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>{clearInterval(timer);refreshSeqRef.current+=1}},[activeId,activeRoomId,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-04 05:41:09 +00:00
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
2026-09-04 14:43:29 +00:00
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);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
2026-09-04 05:41:09 +00:00
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]);
function openPane(part:RightPart){setRightPart(part);setRightCollapsed(false)}
const sessionBusy=workingMembers.length>0;
const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
const chatName=activeRoom?.name||active?.name||"";
2026-09-03 16:16:34 +00:00
2026-09-04 05:41:09 +00:00
async function action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?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:"{}"});}
2026-09-04 14:43:29 +00:00
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"}))}
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}執行「${skill.name}`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
2026-09-04 09:57:33 +00:00
async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();if((!active&&!activeRoom)||!activeSessionId||!text)return;sendingRef.current=true;setDraft("");historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce()})});sentHistoryRef.current.push(text);if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();await loadSessions()})}finally{sendingRef.current=false}}
function composerKeyDown(event:ReactKeyboardEvent<HTMLTextAreaElement>){if(event.nativeEvent.isComposing||event.key==="Process")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<history.length-1){historyIndexRef.current+=1;setDraft(history[historyIndexRef.current])}else{historyIndexRef.current=null;setDraft(historyDraftRef.current)}return}if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();if(sendingRef.current||busy)return;event.currentTarget.form?.requestSubmit()}}
2026-09-04 05:41:09 +00:00
function selectSession(id:string){setActiveSessionId(id);setSessionMenuOpen(false);if(sessionStoreKey)writeSessionStore(sessionStoreKey,id)}
async function createSession(){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{const session=await api<Session>(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
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<Session[]>(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?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?e.message:t("rememberFailed"))}}
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)}}
2026-09-04 05:41:09 +00:00
async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}}
2026-09-03 16:16:34 +00:00
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-04 05:41:09 +00:00
function openBot(bot:Bot){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){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)}
2026-09-04 09:08:56 +00:00
// Re-mount noVNC after every status transition so a freshly booted Docker
// display cannot remain stuck on the previous disconnected iframe.
const startBoot=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/boot`,{method:"POST",body:"{}"})};
const restartComputer=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/restart`,{method:"POST",body:"{}"})};
const frame=screenUrl?<iframe key={`${screenUrl}:${computer.state}:${computer.botId}:${computer.controlHolder}`} className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
2026-09-04 05:41:09 +00:00
const topTools=<nav className="top-tools" aria-label={t("workTools")}>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="computer"?"active":""}`} title={t("computer")} aria-label={t("computer")} onClick={()=>openPane("computer")}><Computer/></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="memory"?"active":""}`} title={t("memory")} aria-label={t("memory")} onClick={()=>openPane("memory")} disabled={!paneBot}><Brain/></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="plugins"?"active":""}`} title={t("plugins")} aria-label={t("plugins")} onClick={()=>openPane("plugins")}><Plug/></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="settings"?"active":""}`} title={active?t("botSettings"):t("settings")} aria-label={active?t("botSettings"):t("settings")} onClick={()=>active?openPane("settings"):setAccountDialog("settings")}><Settings/></button>
</nav>;
2026-09-03 16:16:34 +00:00
2026-09-04 05:41:09 +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:t("loginFailed"))}}}/>;
2026-09-03 23:43:37 +00:00
2026-09-04 09:08:56 +00:00
return <AvatarLookProvider value={looks}><div className={`app-shell ${rightCollapsed?"right-collapsed":"right-open"}`}>
2026-09-03 16:16:34 +00:00
<aside className={`sidebar ${mobileNav?"open":""}`}>
2026-09-04 09:08:56 +00:00
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className="icon-button" onClick={event=>{event.stopPropagation();setCreateMenuOpen(v=>!v)}} aria-label={t("add")} aria-haspopup="menu" aria-expanded={createMenuOpen}><UseAnimations animation={plusToX} size={18} strokeColor="#dfdfe2" wrapperStyle={{pointerEvents:"none"}}/></button>{createMenuOpen&&<div className="create-menu" role="menu"><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/>{t("addBot")}</button><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/>{t("addGroup")}</button></div>}</div></div>
2026-09-04 05:41:09 +00:00
<label className="search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("search")}/></label>
<div className="bot-list">
{filteredRooms.length>0&&<section className="bot-group"><div className="group-label">{t("groups")}</div>{filteredRooms.map(room=><button className={`bot-row room-row ${room.id===activeRoomId?"selected":""}`} key={room.id} onClick={()=>openRoom(room)} onContextMenu={e=>{e.preventDefault();setRoomContext({room,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><AvatarStack members={room.members} online thinkingIds={room.id===activeRoomId?busyMembers.map(member=>member.id):[]}/>{room.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count:room.unreadCount})}/>}</span><span className="bot-copy"><strong>{room.name}</strong><small>{room.lastPreview||t("members",{count:room.members.length})}</small></span>{room.lastMessageAt&&<time className="row-time">{inboxTime(room.lastMessageAt)}</time>}</button>)}</section>}
2026-09-04 09:08:56 +00:00
{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&&!activeRoomId?"selected":""}`} key={bot.id} onClick={()=>openBot(bot)} onContextMenu={e=>{e.preventDefault();setContext({bot,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} active={bot.id===activeId&&!activeRoomId} online/>{bot.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count: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>)}
2026-09-04 05:41:09 +00:00
</div>
<button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<UseAnimations animation={visibility2} size={14} strokeColor="var(--muted)"/>:<UseAnimations animation={visibility} size={14} strokeColor="var(--muted)"/>}{showHidden?t("hideHiddenItems"):t("showHiddenItems")}</button>
<div className="sidebar-bottom">
<button type="button" className={`plugin-row ${rightPart==="plugins"&&!rightCollapsed?"selected":""}`} onClick={()=>openPane("plugins")}>
<span className="plugin-icon"><Plug/></span>
<span className="bot-copy"><strong>{t("plugins")}</strong><small>{mcpServers.filter(server=>server.status==="connected").length>0?t("mcpConnected",{count:mcpServers.filter(server=>server.status==="connected").length}):t("connectMcpServer")}</small></span>
</button>
<div className="account-wrap" onClick={event=>event.stopPropagation()}>
{accountOpen&&<div className="account-menu" role="menu">
<button type="button" role="menuitem" onClick={()=>openAccount("phone")}><Smartphone/>{t("openOnPhone")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("settings")}><Settings/>{t("settings")}</button>
2026-09-04 09:08:56 +00:00
<button type="button" role="menuitem" onClick={()=>openAccount("model")}><BotIcon/>{t("modelSettings")}</button>
2026-09-04 05:41:09 +00:00
<button type="button" role="menuitem" disabled={!paneBot} onClick={()=>{setAccountOpen(false);openPane("memory")}}><Brain/>{t("memory")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("about")}><Info/>{t("about")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("help")}><CircleHelp/>{t("helpCenter")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("feedback")}><Megaphone/>{t("sendFeedback")}</button>
<hr/>
<button type="button" role="menuitem" onClick={()=>void logout()}><LogOut/>{t("logout")}</button>
</div>}
<button type="button" className={`account ${accountOpen?"open":""}`} title={t("workspaceMenu")} aria-haspopup="menu" aria-expanded={accountOpen} onClick={()=>{setAccountOpen(open=>!open);setCreateMenuOpen(false)}}>
<WorkspaceAvatar name={workspaceName}/><span>{workspaceName}</span><ChevronDown className="chevron"/>
</button>
</div>
</div>
2026-09-03 16:16:34 +00:00
</aside>
<main className="chat-panel">
2026-09-04 09:08:56 +00:00
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
2026-09-04 14:43:29 +00:00
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}<span className="message-body">{message.body}</span>{message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{computer.busyStep&&member.id===computer.botId?t("workingStep",{name:member.name,step:computer.busyStep}):t("working",{name:member.name})}</span></div>)}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
2026-09-03 16:16:34 +00:00
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
2026-09-04 05:41:09 +00:00
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
2026-09-04 14:43:29 +00:00
<form className="composer" onSubmit={send}><div className="plus-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className={`composer-plus ${plusOpen?"open":""}`} disabled={!activeSessionId} title={t("moreActions")} aria-label={t("moreActions")} aria-haspopup="menu" aria-expanded={plusOpen} onClick={()=>setPlusOpen(v=>!v)}><Plus/></button>{plusOpen&&<div className="plus-menu" role="menu"><button type="button" role="menuitem" disabled title={t("attachmentsUnavailable")}><Paperclip/>{t("attachFile")}</button><button type="button" role="menuitem" disabled={!active||Boolean(teaching)||Boolean(drafting)} title={active?t("teachTaskHint"):t("teachNeedsBot")} onClick={()=>{setPlusOpen(false);setTeachOpen(true)}}><i className="record-dot"/>{t("teachTask")}</button>{savedSkills.length>0&&<><hr/><small className="plus-menu-label">{t("taughtSkills")}</small>{savedSkills.map(skill=><button type="button" role="menuitem" key={skill.id} title={skill.playbook.whenToUse||skill.goal} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button>)}</>}</div>}</div><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} placeholder={teaching?t("teachingComposerHint"):activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId||Boolean(teaching)}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
2026-09-03 16:16:34 +00:00
</main>
2026-09-04 05:41:09 +00:00
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}
{!rightCollapsed&&<aside className="side-card">
<>
<header className="side-card-head">
<span className="side-card-title">{rightPart==="computer"?t("computer"):rightPart==="memory"?t("memory"):rightPart==="plugins"?t("plugins"):t("settings")}</span>
<button type="button" className="icon-button" title={t("collapseSidebar")} onClick={()=>setRightCollapsed(true)}><ChevronsRight/></button>
</header>
<div className="side-card-body">
<div className={`side-part computer-part ${rightPart==="computer"?"":"hidden-part"}`}>
<div className="computer-status-row">{paneBot?<span>{t("botComputer",{name:paneBot.name})}</span>:<span>{t("computer")}</span>}{computer.state==="booting"?<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/>:<i className={`state-dot ${computer.state}`}/>}<small>{stateLabel(computer.state)}</small></div>
<div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}</div>
2026-09-04 14:43:29 +00:00
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}</>}
2026-09-04 05:41:09 +00:00
</div>
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>}
2026-09-04 09:08:56 +00:00
{rightPart==="settings"&&active&&<BotSettingsPane bot={active} look={looks[active.id]||DEFAULT_LOOK} onLook={look=>{writeAvatarLook(active.id,look);setLooks(readAvatarLooks())}} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>}
2026-09-04 05:41:09 +00:00
</div>
</>
</aside>}
2026-09-03 16:16:34 +00:00
2026-09-04 14:43:29 +00:00
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><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>}
{teachOpen&&active&&<TeachDialog bot={active} busy={busy} close={()=>setTeachOpen(false)} start={goal=>void startTeaching(goal)}/>}
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)}}/>}
2026-09-04 05:41:09 +00:00
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-04 05:41:09 +00:00
{groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async room=>{setGroupOpen(false);setRooms(current=>[room,...current.filter(item=>item.id!==room.id)]);setActiveId(null);setActiveSessionId(null);setBusyMembers([]);setMessages([]);setRightPart("computer");setActiveRoomId(room.id);await loadBots()}}/>}
{deleteOpen&&paneBot&&!activeRoom&&<ConfirmDelete bot={paneBot} close={()=>setDeleteOpen(false)} confirm={()=>action(async()=>{await api(`/api/bots/${paneBot.id}`,{method:"DELETE"});setDeleteOpen(false);setActiveId(null);await loadBots()})}/>}
{clearOpen&&<ConfirmClear close={()=>setClearOpen(false)} confirm={()=>void clearSession()}/>}
{sessionToDelete&&<ConfirmSessionDelete close={()=>setSessionToDelete(null)} confirm={()=>{const id=sessionToDelete;setSessionToDelete(null);void deleteSession(id)}}/>}
{roomToDelete&&<ConfirmRoomDelete room={roomToDelete} close={()=>setRoomToDelete(null)} confirm={()=>void deleteRoom(roomToDelete)}/>}
{context&&<BotContextMenu context={context} close={()=>setContext(null)} run={async(actionName,group)=>{setContext(null);if(actionName==="delete"){setActiveId(context.bot.id);setActiveRoomId(null);setDeleteOpen(true);return}await inbox(context.bot,actionName,group)}}/>}
{roomContext&&<RoomContextMenu context={roomContext} close={()=>setRoomContext(null)} onDelete={()=>{setRoomToDelete(roomContext.room);setRoomContext(null)}}/>}
{accountDialog==="phone"&&<PhoneAccessDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="settings"&&<WorkspaceSettingsDialog name={workspaceName} setName={setWorkspaceName} showHidden={showHidden} setShowHidden={setShowHidden} rightCollapsed={rightCollapsed} setRightCollapsed={setRightCollapsed} close={()=>setAccountDialog(null)}/>}
2026-09-04 09:08:56 +00:00
{accountDialog==="model"&&<ModelSettingsDialog close={()=>setAccountDialog(null)}/>}
2026-09-04 05:41:09 +00:00
{accountDialog==="about"&&<AboutDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="help"&&<HelpDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="feedback"&&<FeedbackDialog close={()=>setAccountDialog(null)}/>}
2026-09-04 09:08:56 +00:00
</div></AvatarLookProvider>
2026-09-04 05:41:09 +00:00
}
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?t("unpin"):t("pin")}</button><button onClick={()=>run("unread")}><UseAnimations animation={mail} size={15} strokeColor="#dfdfe2"/>{t("markUnread")}</button><button onClick={()=>{const name=window.prompt(t("enterGroupName"),bot.groupName||"");if(name!==null)run("group",name)}}><UseAnimations animation={folder} size={15} strokeColor="#dfdfe2"/>{bot.groupName?t("changeGroup"):t("createOrMoveGroup")}</button>{bot.groupName&&<button onClick={()=>run("group",null)}><X/>{t("removeFromGroup")}</button>}<button onClick={()=>run(bot.hidden?"show":"hide")}>{bot.hidden?<UseAnimations animation={visibility} size={15} strokeColor="#dfdfe2"/>:<UseAnimations animation={visibility2} size={15} strokeColor="#dfdfe2"/>}{bot.hidden?t("unhide"):t("hide")}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><UseAnimations animation={trash2} size={15} strokeColor="#ff7777"/>{t("delete")}</button><button className="context-close" onClick={close}><X/></button></div>}
function SessionMenu({sessions,activeSessionId,open,setOpen,busy,onSelect,onCreate,onDelete,onClear}:{sessions:Session[];activeSessionId:string|null;open:boolean;setOpen:(fn:(value:boolean)=>boolean)=>void;busy:boolean;onSelect:(id:string)=>void;onCreate:()=>void;onDelete:(id:string)=>void;onClear:()=>void}){
const active=sessions.find(session=>session.id===activeSessionId);
return <div className="session-picker" onClick={event=>event.stopPropagation()}>
<button type="button" className="session-current" onClick={()=>setOpen(value=>!value)} aria-label={t("chooseConversation")} aria-expanded={open}>
<span>{active?.title||t("chooseConversation")}</span><ChevronDown/>
</button>
<button type="button" className="icon-button" onClick={onCreate} title={t("newConversation")} disabled={busy}><Plus/></button>
{open&&<div className="session-menu">
<button type="button" onClick={onCreate} disabled={busy}><Plus/>{t("newConversation")}</button>
<hr/>
<div className="session-menu-list">{sessions.map(session=><div key={session.id} className={`session-item ${session.id===activeSessionId?"selected":""}`}>
<button type="button" className="session-item-main" onClick={()=>onSelect(session.id)}>
<strong>{session.title}</strong>
<time>{inboxTime(session.updatedAt)}</time>
</button>
<button type="button" className="icon-button" title={t("deleteConversation")} disabled={busy} onClick={()=>onDelete(session.id)}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button>
</div>)}</div>
<hr/>
<button type="button" className="danger-item" disabled={busy||!activeSessionId} onClick={onClear}>{t("clearConversation")}</button>
</div>}
2026-09-03 16:16:34 +00:00
</div>
}
2026-09-04 05:41:09 +00:00
function ConfirmClear({close,confirm}:{close:()=>void;confirm:()=>void}){
return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("clearConversationTitle")}</h2><p>{t("clearConversationDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("clearMessages")}</button></div></div></div>
}
function ConfirmSessionDelete({close,confirm}:{close:()=>void;confirm:()=>void}){
return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteConversationTitle")}</h2><p>{t("deleteConversationDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteConversation")}</button></div></div></div>
}
2026-09-03 16:16:34 +00:00
2026-09-04 09:08:56 +00:00
const EXPR_I18N = {idle:"exprIdle",happy:"exprHappy",sad:"exprSad",mad:"exprMad",surprised:"exprSurprised",wink:"exprWink",sleepy:"exprSleepy",smug:"exprSmug",unsure:"exprUnsure",scared:"exprScared",love:"exprLove",shy:"exprShy",sick:"exprSick",thinking:"exprThinking"} as const satisfies Record<AvatarExpression, MessageKey>;
const BG_I18N = {none:"bgNone",circle:"bgCircle",squircle:"bgSquircle",square:"bgSquare"} as const satisfies Record<AvatarBackground, MessageKey>;
function BotSettingsPane({bot,look,onLook,saved,onDelete}:{bot:Bot;look:AvatarLook;onLook:(look:AvatarLook)=>void;saved:()=>Promise<void>|void;onDelete:()=>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>(resolveBlobatarShape(bot.avatarShape));const[expression,setExpression]=useState<AvatarExpression>(look.expression);const[background,setBackground]=useState<AvatarBackground>(look.background);const[tagText,setTagText]=useState((bot.tags||[]).join("、"));const[busy,setBusy]=useState(false);
useEffect(()=>{setName(bot.name);setTitle(bot.title);setDescription(bot.description);setColor(bot.avatarColor||"#8B5CF6");setShape(resolveBlobatarShape(bot.avatarShape));setExpression(look.expression);setBackground(look.background);setTagText((bot.tags||[]).join("、"))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags,look.expression,look.background]);
2026-09-04 05:41:09 +00:00
const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];
2026-09-04 09:08:56 +00:00
const face={name:name||bot.name,color,shape,expression,background};
return <form className="pane-form settings-pane" 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:persistBlobatarShape(shape),tags:tagText.split(/[、,]/).map(v=>v.trim()).filter(Boolean)})});onLook({expression,background});await saved()}finally{setBusy(false)}}}>
<div className="avatar-editor"><Avatar {...face} size={96}/><strong>{t("avatarAppearance")}</strong><small>{t("avatarAppearanceHint")}</small></div>
2026-09-04 05:41:09 +00:00
<fieldset><legend>{t("color")}</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={t("chooseColor",{value})}/>)}<label className="custom-color" title={t("customColor")}><input type="color" value={color} onChange={e=>setColor(e.target.value.toUpperCase())}/><span></span></label></div></fieldset>
2026-09-04 09:08:56 +00:00
<fieldset><legend>{t("shape")}</legend><div className="shape-grid">{BLOBATAR_SHAPES.map(value=><button type="button" className={resolveBlobatarShape(shape)===value?"selected":""} onClick={()=>setShape(value)} key={value}><Avatar {...face} shape={value} gaze={false}/></button>)}</div></fieldset>
<fieldset><legend>{t("expression")}</legend><div className="expr-grid">{BLOBATAR_EXPRESSIONS.map(value=><button type="button" className={expression===value?"selected":""} onClick={()=>setExpression(value)} key={value}><Avatar {...face} expression={value} gaze={false} size={36}/><span>{t(EXPR_I18N[value])}</span></button>)}</div></fieldset>
<fieldset><legend>{t("background")}</legend><div className="bg-grid">{BLOBATAR_BACKGROUNDS.map(value=><button type="button" className={background===value?"selected":""} onClick={()=>setBackground(value)} key={value}><Avatar {...face} background={value} gaze={false} size={36}/><span>{t(BG_I18N[value])}</span></button>)}</div></fieldset>
2026-09-04 05:41:09 +00:00
<label>{t("name")}<input value={name} maxLength={80} onChange={e=>setName(e.target.value)}/></label>
<label>{t("tags")}<input value={tagText} maxLength={120} onChange={e=>setTagText(e.target.value)} placeholder={t("tagsPlaceholder")}/><small>{t("tagsLimit")}</small></label>
<label>{t("shortTitle")}<input value={title} maxLength={100} onChange={e=>setTitle(e.target.value)} placeholder={t("shortTitlePlaceholder")}/></label>
<label>{t("description")}<textarea value={description} maxLength={1000} rows={4} onChange={e=>setDescription(e.target.value)} placeholder={t("descriptionPlaceholder")}/></label>
<div className="pane-actions"><button className="primary" disabled={busy||!name.trim()}>{busy?t("saving"):t("saveSettings")}</button><button type="button" className="danger" onClick={onDelete}>{t("deleteBot")}</button></div>
</form>
}
2026-09-03 16:16:34 +00:00
2026-09-04 05:41:09 +00:00
function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[query,setQuery]=useState("");
const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
const[editingId,setEditingId]=useState<string|null>(null);const[editingText,setEditingText]=useState("");const[confirmClear,setConfirmClear]=useState(false);
2026-09-03 23:43:37 +00:00
const load=useCallback(()=>api<MemoryItem[]>(`/api/bots/${bot.id}/memories`).then(setItems),[bot.id]);
2026-09-04 05:41:09 +00:00
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
useEffect(()=>setEnabled(bot.memoryEnabled),[bot.id,bot.memoryEnabled]);
const visible=items.filter(item=>!query.trim()||item.content.toLowerCase().includes(query.trim().toLowerCase()));
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}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:t("settingsFailed"))}}
return <div className="memory-pane">
<p className="memory-help">{t("memoryHelp")}</p>
<label className="memory-toggle"><input type="checkbox" checked={enabled} onChange={e=>void toggle(e.target.checked)}/> {t("enableLongTermMemory")}</label>
2026-09-03 23:43:37 +00:00
<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("")})}}>
2026-09-04 05:41:09 +00:00
<label>{t("addMemory")}<textarea value={draft} onChange={e=>setDraft(e.target.value)} rows={3} placeholder={t("memoryPlaceholder")}/></label>
<button className="primary" disabled={busy||!draft.trim()}>{t("add")}</button>
2026-09-03 23:43:37 +00:00
</form>
2026-09-04 05:41:09 +00:00
{items.length>0&&<label className="memory-search">{t("memorySearch")}<input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("filterMemory")}/></label>}
<div className="memory-list">{items.length===0?<p>{t("noMemory")}</p>:visible.length===0?<p>{t("noMatchingMemory")}</p>:visible.map(item=><div className="memory-row" key={item.id}>
{editingId===item.id?<textarea value={editingText} onChange={e=>setEditingText(e.target.value)} rows={3}/>:<span>{item.content}</span>}
<small>{inboxTime(item.updatedAt)}</small>
<div>{editingId===item.id?<>
<button className="primary" disabled={busy||!editingText.trim()} onClick={()=>void run(async()=>{await api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"PATCH",body:JSON.stringify({content:editingText,importance:item.importance})});setEditingId(null)})}>{t("save")}</button>
<button className="outline" disabled={busy} onClick={()=>setEditingId(null)}>{t("cancel")}</button>
</>:<>
<button className="outline" disabled={busy} onClick={()=>{setEditingId(item.id);setEditingText(item.content)}}>{t("edit")}</button>
<button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"DELETE"}))}>{t("delete")}</button>
</>}</div>
</div>)}</div>
{error&&<div className="pane-error">{error}</div>}
<div className="pane-actions">
{confirmClear?<span className="memory-clear-confirm">{t("clearAllMemoryConfirm")}<button className="danger" disabled={busy} onClick={()=>void run(async()=>{await api(`/api/bots/${bot.id}/memories`,{method:"DELETE"});setConfirmClear(false)})}>{t("clearMemoryConfirm")}</button><button className="outline" onClick={()=>setConfirmClear(false)}>{t("cancel")}</button></span>:<button className="danger" disabled={busy||items.length===0} onClick={()=>setConfirmClear(true)}>{t("clearAll")}</button>}
</div>
</div>
2026-09-03 23:43:37 +00:00
}
2026-09-04 05:41:09 +00:00
function parsePairs(text:string){const out:Record<string,string>={};for(const line of text.split(/\n+/)){const trimmed=line.trim();if(!trimmed)continue;const cut=trimmed.indexOf("=");if(cut<=0)continue;out[trimmed.slice(0,cut).trim()]=trimmed.slice(cut+1)}return out}
function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}){
2026-09-04 09:08:56 +00:00
const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null);
2026-09-04 05:41:09 +00:00
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
return <div className="mcp-pane">
<p className="memory-help">{t("mcpHelp")}</p>
2026-09-04 09:08:56 +00:00
<button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button>
{picker&&<McpPickerDialog added={servers.map(server=>server.name)} close={()=>setPicker(false)} connected={async()=>{setPicker(false);await reload()}}/>}
2026-09-04 05:41:09 +00:00
<div className="mcp-list">{servers.length===0?<p>{t("noMcp")}</p>:servers.map(server=><div className={`mcp-card ${server.status}`} key={server.id}>
<button type="button" className="mcp-card-head" onClick={()=>setOpenId(id=>id===server.id?null:server.id)}>
<i className={`mcp-dot ${server.status}`}/><strong>{server.name}</strong>
<small>{server.status==="connected"?t("toolsCount",{count:server.tools.length}):server.status==="disabled"?t("disabled"):t("disconnected")}</small>
</button>
{server.error&&<div className="mcp-error">{server.error}</div>}
{openId===server.id&&<>
<div className="mcp-tools">{server.tools.length===0?<span>{t("noTools")}</span>:server.tools.map(tool=><div className="mcp-tool" key={tool.exposedName}><code>{tool.exposedName}</code><span>{tool.description||tool.name}</span></div>)}</div>
<div className="mcp-card-actions">
<button className="outline" disabled={busy} onClick={()=>void run(()=>api(`/api/mcp-servers/${server.id}/reconnect`,{method:"POST",body:"{}"}))}><RefreshCw/>{t("reconnect")}</button>
<button className="outline" disabled={busy} onClick={()=>void run(()=>api(`/api/mcp-servers/${server.id}`,{method:"PATCH",body:JSON.stringify({enabled:!server.enabled})}))}>{server.enabled?t("disable"):t("enable")}</button>
<button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/mcp-servers/${server.id}`,{method:"DELETE"}))}>{t("delete")}</button>
</div>
</>}
</div>)}</div>
{error&&<div className="pane-error">{error}</div>}
</div>
}
2026-09-04 09:08:56 +00:00
function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;connected:()=>Promise<void>}){
const[query,setQuery]=useState("");const[custom,setCustom]=useState(false);
const[items,setItems]=useState<McpCatalogEntry[]>([]);const[loading,setLoading]=useState(true);
const[picked,setPicked]=useState<McpCatalogEntry|null>(null);
const[secrets,setSecrets]=useState<Record<string,string>>({});
const[busy,setBusy]=useState(false);const[error,setError]=useState("");
const addedSet=useMemo(()=>new Set(added.map(name=>name.toLowerCase())),[added]);
useEffect(()=>{const timer=setTimeout(()=>{setLoading(true);api<{servers:McpCatalogEntry[]}>(`/api/mcp-catalog?q=${encodeURIComponent(query.trim())}`).then(result=>setItems(result.servers)).catch(e=>setError(e instanceof Error?e.message:t("loadFailed"))).finally(()=>setLoading(false))},query?280:0);return()=>clearTimeout(timer)},[query]);
function needsSecrets(entry:McpCatalogEntry){return entry.envKeys.some(field=>field.required)||entry.headerKeys.some(field=>field.required)}
async function connect(entry:McpCatalogEntry,values:Record<string,string>){
const env:Record<string,string>={};const headers:Record<string,string>={};
for(const field of entry.envKeys){const value=values[field.name]?.trim();if(value)env[field.name]=value;else if(field.required){setError(t("mcpKeyHint"));return}}
for(const field of entry.headerKeys){const value=values[field.name]?.trim();if(value)headers[field.name]=value;else if(field.required){setError(t("mcpKeyHint"));return}}
setBusy(true);setError("");
try{
const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify({name:entry.title.slice(0,80),transport:entry.transport,command:entry.command,args:entry.args,url:entry.url,env,headers,enabled:true})});
if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));return}
await connected();
}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}
finally{setBusy(false)}
}
function choose(entry:McpCatalogEntry){
if(addedSet.has(entry.title.toLowerCase())||addedSet.has(entry.id.toLowerCase()))return;
if(needsSecrets(entry)){setPicked(entry);setSecrets({});return}
void connect(entry,{});
}
return <div className="modal-backdrop" onClick={close}><div className="dialog mcp-picker" onClick={e=>e.stopPropagation()}>
<div className="dialog-title"><h2>{t("mcpPickerTitle")}</h2><button type="button" onClick={close}><X/></button></div>
{picked?<div className="mcp-secret-step">
<button type="button" className="outline mcp-back" onClick={()=>setPicked(null)}>{t("mcpBackToList")}</button>
<strong>{t("mcpConnectNamed",{name:picked.title})}</strong>
<p className="dialog-lead">{t("mcpKeyHint")}</p>
{[...picked.envKeys,...picked.headerKeys].map(field=><label key={field.name}>{field.name}{field.required?" *":""}<input type={field.secret?"password":"text"} autoComplete="off" value={secrets[field.name]||""} onChange={e=>setSecrets(current=>({...current,[field.name]:e.target.value}))} placeholder={field.hint||field.name}/></label>)}
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={()=>setPicked(null)}>{t("cancel")}</button><button type="button" className="primary" disabled={busy} onClick={()=>void connect(picked,secrets)}>{busy?t("connecting"):t("connectMcp")}</button></div>
</div>:custom?<McpCustomForm busy={busy} error={error} onBack={()=>setCustom(false)} onSubmit={async payload=>{setBusy(true);setError("");try{const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify(payload)});if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));setBusy(false);return}await connected()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"));setBusy(false)}}}/>:<>
<p className="dialog-lead">{t("mcpPickerLead")}</p>
<label className="mcp-picker-search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input autoFocus value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("searchMcp")}/></label>
<div className="mcp-picker-grid">{loading&&items.length===0?<div className="mcp-picker-empty">{t("connecting")}</div>:items.length===0?<div className="mcp-picker-empty">{t("mcpNoResults")}</div>:items.map(entry=>{const already=addedSet.has(entry.title.toLowerCase())||addedSet.has(entry.id.toLowerCase());return <button type="button" className={`mcp-pick-card ${already?"added":""}`} key={entry.id} disabled={already||busy} onClick={()=>choose(entry)}>
<span className="mcp-pick-icon">{entry.title.slice(0,1).toUpperCase()}</span>
<strong>{entry.title}</strong>
<small>{entry.description}</small>
<span className="mcp-pick-meta">{already?t("mcpAdded"):entry.remote?t("mcpRemote"):t("mcpLocal")}{needsSecrets(entry)&&!already?` · ${t("mcpNeedsKey")}`:""}</span>
</button>})}</div>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={()=>setCustom(true)}>{t("mcpCustom")}</button><button type="button" className="outline" onClick={close}>{t("close")}</button></div>
</>}
</div></div>
}
function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;onBack:()=>void;onSubmit:(payload:Record<string,unknown>)=>Promise<void>}){
const[name,setName]=useState("");const[transport,setTransport]=useState<McpTransport>("http");
const[command,setCommand]=useState("");const[args,setArgs]=useState("");const[url,setUrl]=useState("");
const[envText,setEnvText]=useState("");const[headerText,setHeaderText]=useState("");
return <form className="mcp-add" onSubmit={e=>{e.preventDefault();const trimmed=name.trim();if(!trimmed)return;void onSubmit({name:trimmed,transport,command:command.trim()||null,args:args.trim()?args.trim().split(/\s+/):[],url:url.trim()||null,env:parsePairs(envText),headers:parsePairs(headerText),enabled:true})}}>
<button type="button" className="outline mcp-back" onClick={onBack}>{t("mcpBackToList")}</button>
<label>{t("name")}<input autoFocus value={name} maxLength={80} onChange={e=>setName(e.target.value)} placeholder={t("mcpNamePlaceholder")}/></label>
<div className="mcp-transport">{(["http","sse","stdio"] as McpTransport[]).map(value=><button type="button" key={value} className={transport===value?"picked":""} onClick={()=>setTransport(value)}>{value==="http"?"HTTP":value==="sse"?"SSE":"stdio"}</button>)}</div>
{transport==="stdio"?<>
<label>{t("command")}<input value={command} onChange={e=>setCommand(e.target.value)} placeholder={t("commandPlaceholder")}/></label>
<label>{t("arguments")}<input value={args} onChange={e=>setArgs(e.target.value)} placeholder={t("argumentsPlaceholder")}/></label>
<label>{t("environmentVariables")}<textarea rows={3} value={envText} onChange={e=>setEnvText(e.target.value)} placeholder={t("environmentVariablesPlaceholder")}/></label>
</>:<>
<label>{t("url")}<input value={url} onChange={e=>setUrl(e.target.value)} placeholder={t("urlPlaceholder")}/></label>
<label>{t("headers")}<textarea rows={3} value={headerText} onChange={e=>setHeaderText(e.target.value)} placeholder={t("headersPlaceholder")}/></label>
</>}
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={onBack}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()||(transport==="stdio"?!command.trim():!url.trim())}>{busy?t("connecting"):t("connectMcp")}</button></div>
</form>
}
2026-09-04 05:41:09 +00:00
function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<UseAnimations animation={loading2} size={38} wrapperStyle={{display:"block"}}/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?t("preparingDesktop"):t("computerPreviewHint")}</span></div>}
2026-09-04 09:08:56 +00:00
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const working=Boolean(computer.busyBotName);const restart=<button className="outline restart-computer" disabled={busy} title={t("restartDocker")} onClick={()=>void action(onRestart||(()=>api(`/api/computer/${active.id}/restart`,{method:"POST",body:"{}"})))}><RefreshCw/> {t("restartDocker")}</button>;if(computer.state!=="running")return <div className="computer-actions"><button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>void action(onBoot||(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"})))}>{(busy||computer.state==="booting")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>{(computer.state==="booting"||computer.state==="error")&&restart}</div>;if(working)return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button><button className="outline" disabled={busy} onClick={()=>action(async()=>{if(sessionId)await api(`/api/sessions/${sessionId}/stop`,{method:"POST",body:"{}"});else await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});})}><Square/>{t("stopTask")}</button>{restart}</div>;if(computer.controlHolder==="user")return <div className="computer-actions"><button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>{restart}</div>;return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>{restart}</div>}
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<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}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button></div>}
2026-09-04 14:43:29 +00:00
function TeachDialog({bot,busy,close,start}:{bot:Bot;busy:boolean;close:()=>void;start:(goal:string)=>void}){
const [goal,setGoal]=useState("");
return <div className="modal-backdrop" onClick={close}><form className="dialog teach-dialog" onClick={e=>e.stopPropagation()} onSubmit={e=>{e.preventDefault();if(goal.trim())start(goal.trim())}}>
<div className="dialog-title"><h2><i className="record-dot"/>{t("teachTask")}</h2><button type="button" onClick={close}><X/></button></div>
<p>{t("teachDialogLead",{name:bot.name})}</p>
<label>{t("teachGoalLabel")}<textarea className="teach-goal" autoFocus rows={3} value={goal} onChange={e=>setGoal(e.target.value)} placeholder={t("teachGoalPlaceholder")} onKeyDown={e=>{if(e.key==="Enter"&&!e.shiftKey&&!e.nativeEvent.isComposing){e.preventDefault();e.currentTarget.form?.requestSubmit()}}}/></label>
<ul className="teach-tips"><li>{t("teachTip1")}</li><li>{t("teachTip2")}</li><li>{t("teachTip3")}</li></ul>
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!goal.trim()}>{t("startDemo")}</button></div>
</form></div>;
}
function stepText(step:unknown):string{if(typeof step==="string")return step;if(step&&typeof step==="object"&&"do" in step)return String((step as {do:unknown}).do||"");return ""}
function SkillDraftCard({skill,busy,onSave,onTest,onDiscard}:{skill:TaughtSkill;busy:boolean;onSave:(name:string,playbook:Playbook)=>void;onTest:(name:string,playbook:Playbook)=>void;onDiscard:()=>void}){
const [name,setName]=useState(skill.name||skill.playbook.name||"");
const [stepsText,setStepsText]=useState((skill.playbook.steps||[]).map(step=>stepText(step)).join("\n"));
const [expanded,setExpanded]=useState(false);
const steps=skill.playbook.steps||[];
const build=():Playbook=>{const lines=stepsText.split("\n").map(line=>line.replace(/^\s*\d+[.、)]\s*/,"").trim()).filter(Boolean);const next=lines.map((line,index)=>{const prev=steps[index];return typeof prev==="object"&&prev&&stepText(prev)===line?prev:{do:line,expect:typeof prev==="object"&&prev&&stepText(prev)===line?prev.expect:"",note:""}});return {...skill.playbook,name:name.trim(),steps:next}};
const inputs=(skill.playbook.inputs||[]).map(input=>input.name).filter(Boolean);
return <section className="skill-draft" aria-label={t("skillDraftTitle")}>
<header><Sparkle/><strong>{t("skillDraftTitle")}</strong><small>{t("skillDraftFrom",{count:skill.eventCount})}</small></header>
<label className="skill-name">{t("skillName")}<input value={name} maxLength={40} onChange={e=>setName(e.target.value)} placeholder={t("skillNamePlaceholder")}/></label>
{skill.playbook.intent&&<p className="skill-intent">{skill.playbook.intent}</p>}
{inputs.length>0&&<p className="skill-inputs">{t("skillInputs")}{inputs.map(input=><code key={input}>{input}</code>)}</p>}
<label className="skill-steps">{t("skillSteps")}{expanded?<textarea rows={Math.min(12,Math.max(4,stepsText.split("\n").length+1))} value={stepsText} onChange={e=>setStepsText(e.target.value)}/>:<ol onClick={()=>setExpanded(true)}>{stepsText.split("\n").filter(Boolean).slice(0,6).map((line,index)=><li key={index}>{line}</li>)}{stepsText.split("\n").filter(Boolean).length>6&&<li className="more">{t("moreSteps",{count:stepsText.split("\n").filter(Boolean).length-6})}</li>}</ol>}{!expanded&&<button type="button" className="link" onClick={()=>setExpanded(true)}>{t("editSteps")}</button>}</label>
{skill.playbook.howToCheck&&<p className="skill-check">{t("skillCheck")}{skill.playbook.howToCheck}</p>}
{skill.error&&<p className="skill-error">{t("skillDistillFailed")}</p>}
<div className="skill-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={onDiscard}>{t("discard")}</button><span className="grow"/><button type="button" className="outline" disabled={busy||!name.trim()} onClick={()=>onTest(name.trim(),build())}>{t("testRun")}</button><button type="button" className="primary" disabled={busy||!name.trim()} onClick={()=>onSave(name.trim(),build())}>{t("saveSkill")}</button></div>
</section>;
}
2026-09-04 05:41:09 +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>{t("pasteToRemoteComputer")}</h2><button onClick={close}><X/></button></div><p>{t("pasteRemoteHelp")}</p><textarea className="clipboard-text" autoFocus value={text} onChange={e=>setText(e.target.value)} placeholder={t("pasteTextPlaceholder")}/><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={!text} onClick={()=>paste(text)}>{t("pasteIntoVnc")}</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>{t("addBot")}</h2><button type="button" onClick={close}><X/></button></div><label>{t("name")}<input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={t("botNamePlaceholder")}/></label><div className="mode-grid"><button type="button" className={mode==="team"?"picked":""} onClick={()=>setMode("team")}><BotIcon/><strong>{t("sharedComputer")}</strong><small>{t("sharedComputerHint")}</small></button><button type="button" className={mode==="dedicated"?"picked":""} onClick={()=>setMode("dedicated")}><Computer/><strong>{t("privateComputer")}</strong><small>{t("privateComputerHint")}</small></button></div><div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()}>{t("create")}</button></div></form></div>}
2026-09-04 09:08:56 +00:00
function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:(room:Room)=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState<string[]>([]);const[busy,setBusy]=useState(false);const visible=bots.filter(bot=>!bot.hidden);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();const groupName=name.trim();if(!groupName||selected.length<2)return;setBusy(true);try{created(await api<Room>("/api/rooms",{method:"POST",body:JSON.stringify({name:groupName,memberIds:selected})}))}finally{setBusy(false)}}}><div className="dialog-title"><h2>{t("addGroup")}</h2><button type="button" onClick={close}><X/></button></div><p className="dialog-lead">{t("groupDescription")}</p><label>{t("groupName")}<input autoFocus value={name} maxLength={30} onChange={e=>setName(e.target.value)} placeholder={t("groupNamePlaceholder")}/></label><fieldset className="group-picker"><legend>{t("chooseBots")}</legend>{visible.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 lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} online/><span>{bot.name}</span></label>)}</fieldset><div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()||selected.length<2}>{busy?t("creating"):t("createGroup")}</button></div></form></div>}
2026-09-04 05:41:09 +00:00
function RoomContextMenu({context,close,onDelete}:{context:{room:Room;x:number;y:number};close:()=>void;onDelete:()=>void}){return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-160)}} onClick={e=>e.stopPropagation()}><button className="danger-item" onClick={onDelete}><UseAnimations animation={trash2} size={15} strokeColor="#ff7777"/>{t("deleteGroup")}</button><button className="context-close" onClick={close}><X/></button></div>}
function ConfirmRoomDelete({room,close,confirm}:{room:Room;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteNamed",{name:room.name})}</h2><p>{t("deleteGroupDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteGroup")}</button></div></div></div>}
function ConfirmDelete({bot,close,confirm}:{bot:Bot;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteNamed",{name:bot.name})}</h2><p>{bot.computerMode==="dedicated"?t("deleteDedicatedBotDescription"):t("deleteSharedBotDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("delete")}</button></div></div></div>}
function PhoneAccessDialog({close}:{close:()=>void}){
const url=location.origin;
const local=/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(url);
const[copied,setCopied]=useState(false);
return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("openOnPhone")}</h2><button type="button" onClick={close}><X/></button></div>
<p>{t("phoneAccessDescription")}</p>
<code className="share-url">{url}</code>
{local&&<p className="dialog-lead">{t("localAddressWarning")}</p>}
<div className="dialog-actions"><button className="outline" onClick={close}>{t("close")}</button><button className="primary" onClick={async()=>{try{await navigator.clipboard.writeText(url);setCopied(true)}catch{setCopied(false)}}}>{copied?t("copied"):t("copyUrl")}</button></div>
</div></div>
}
function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCollapsed,setRightCollapsed,close}:{name:string;setName:(value:string)=>void;showHidden:boolean;setShowHidden:(value:boolean)=>void;rightCollapsed:boolean;setRightCollapsed:(value:boolean)=>void;close:()=>void}){
const[draft,setDraft]=useState(name);
return <div className="modal-backdrop"><form className="dialog compact" onSubmit={e=>{e.preventDefault();setName(draft.trim()||t("localWorkspace"));close()}}>
<div className="dialog-title"><h2>{t("settings")}</h2><button type="button" onClick={close}><X/></button></div>
<label>{t("workspaceName")}<input autoFocus value={draft} maxLength={40} onChange={e=>setDraft(e.target.value)}/></label>
<label className="memory-toggle"><input type="checkbox" checked={showHidden} onChange={e=>setShowHidden(e.target.checked)}/> {t("showHiddenBots")}</label>
<label className="memory-toggle"><input type="checkbox" checked={rightCollapsed} onChange={e=>setRightCollapsed(e.target.checked)}/> {t("collapseRightSidebar")}</label>
<p className="dialog-lead">{t("workspaceSettingsHint")}</p>
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div>
</form></div>
}
2026-09-04 09:08:56 +00:00
function ModelSettingsDialog({close}:{close:()=>void}){
const[settings,setSettings]=useState<WorkspaceSettings|null>(null);
const[provider,setProvider]=useState<ModelProviderId>("xai");
const[modelId,setModelId]=useState("");
const[baseUrl,setBaseUrl]=useState("");
const[apiKey,setApiKey]=useState("");
const[clearKey,setClearKey]=useState(false);
const[models,setModels]=useState<{id:string;name:string}[]>([]);
const[busy,setBusy]=useState(false);
const[error,setError]=useState("");
const current=settings?.providers.find(item=>item.id===provider);
useEffect(()=>{api<WorkspaceSettings>("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[]);
async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
try{
const query=new URLSearchParams({provider:nextProvider});
if(nextBaseUrl.trim())query.set("baseUrl",nextBaseUrl.trim());
const result=await api<{models:{id:string;name:string}[]}>(`/api/workspace/models?${query}`);
setModels(result.models);
setModelId(current=>result.models.some(item=>item.id===current)?current:result.models[0]?.id||current);
}catch{if(nextProvider==="openai-compatible")setModels([])}
}
function pickProvider(id:ModelProviderId){
setProvider(id);
const info=settings?.providers.find(item=>item.id===id);
if(id==="openai-compatible"){
setModels([]);
setBaseUrl(current=>current.includes("opencode.ai")||current.includes("api.x.ai")?"":current);
if(info?.defaultModel)setModelId(info.defaultModel);else setModelId("");
return;
}
if(info?.defaultBaseUrl)setBaseUrl(info.defaultBaseUrl);
if(info?.defaultModel)setModelId(info.defaultModel);
void loadModels(id,info?.defaultBaseUrl||"");
}
return <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{e.preventDefault();setBusy(true);setError("");try{await api("/api/workspace/settings",{method:"PATCH",body:JSON.stringify({provider,modelId:modelId.trim(),baseUrl:baseUrl.trim()||null,apiKey:clearKey?"":apiKey.trim()||null,clearApiKey:clearKey})});close()}catch(e){setError(e instanceof Error?e.message:t("settingsFailed"))}finally{setBusy(false)}}}>
<div className="dialog-title"><h2>{t("modelSettings")}</h2><button type="button" onClick={close}><X/></button></div>
<p className="dialog-lead">{t("modelSettingsHint")}</p>
<fieldset className="provider-fieldset"><legend>{t("modelProvider")}</legend>
<div className="provider-grid">{(settings?.providers||[{id:"xai" as const,name:t("providerXai")},{id:"opencode-go" as const,name:t("providerOpencodeGo")},{id:"openai-compatible" as const,name:t("providerOpenaiCompatible")}]).map(item=><button type="button" key={item.id} className={provider===item.id?"picked":""} onClick={()=>pickProvider(item.id)}>{item.id==="xai"?t("providerXai"):item.id==="opencode-go"?t("providerOpencodeGo"):t("providerOpenaiCompatible")}</button>)}</div>
<p className="dialog-lead">{provider==="xai"?t("providerXaiHint"):provider==="opencode-go"?t("providerOpencodeGoHint"):t("providerOpenaiCompatibleHint")}</p>
</fieldset>
<label>{t("apiKey")}<input type="password" autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);setClearKey(false)}} placeholder={settings?.apiKeySet?t("apiKeyStored"):t("apiKeyPlaceholder")}/></label>
{settings?.envKeySet&&settings.provider===provider&&!apiKey&&!clearKey&&<p className="dialog-lead">{t("usingEnvKey",{name:settings.envKeyName})}</p>}
{settings?.apiKeySet&&<label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={e=>setClearKey(e.target.checked)}/> {t("clearApiKey")}</label>}
{(current?.needsBaseUrl||provider==="openai-compatible")&&<label>{t("baseUrl")}<input value={baseUrl} onChange={e=>setBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")}/><small>{t("baseUrlHint")}</small></label>}
<label>{t("modelId")}
{models.length>0?<select value={models.some(item=>item.id===modelId)?modelId:""} onChange={e=>setModelId(e.target.value)}>
{models.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}
{!models.some(item=>item.id===modelId)&&modelId?<option value={modelId}>{modelId}</option>:null}
</select>:<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
{models.length>0&&<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
</label>
<button type="button" className="outline" onClick={()=>void loadModels(provider,baseUrl)}>{t("reloadModels")}</button>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!modelId.trim()||(provider==="openai-compatible"&&!baseUrl.trim())}>{busy?t("saving"):t("save")}</button></div>
</form></div>
}
2026-09-04 05:41:09 +00:00
function AboutDialog({close}:{close:()=>void}){
const[health,setHealth]=useState<"ok"|"bad"|"…">("…");
useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]);
return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("about")}</h2><button type="button" onClick={close}><X/></button></div>
<div className="about-brand"><Avatar name="L" size={52} online/><strong>LazyBoy</strong><small>0.1.0</small></div>
<p>{t("aboutDescription")}</p>
<p className="dialog-lead">{t("apiStatus",{status:health==="ok"?t("statusNormal"):health==="bad"?t("statusUnavailable"):t("statusChecking")})}</p>
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
</div></div>
}
function HelpDialog({close}:{close:()=>void}){
return <div className="modal-backdrop"><div className="dialog help-dialog"><div className="dialog-title"><h2>{t("helpCenter")}</h2><button type="button" onClick={close}><X/></button></div>
<div className="help-body">
<section><h3>{t("helpBotsTitle")}</h3><p>{t("helpBots")}</p></section>
<section><h3>{t("helpGroupsTitle")}</h3><p>{t("helpGroups")}</p></section>
<section><h3>{t("helpComputerTitle")}</h3><p>{t("helpComputer")}</p></section>
<section><h3>{t("helpMemoryTitle")}</h3><p>{t("helpMemory")}</p></section>
<section><h3>{t("helpMcpTitle")}</h3><p>{t("helpMcp")}</p></section>
<section><h3>{t("helpShortcutsTitle")}</h3><p>{t("helpShortcuts")}</p></section>
</div>
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
</div></div>
}
function FeedbackDialog({close}:{close:()=>void}){
const[text,setText]=useState("");const[copied,setCopied]=useState(false);
return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("sendFeedback")}</h2><button type="button" onClick={close}><X/></button></div>
<p>{t("feedbackDescription")}</p>
<textarea className="clipboard-text" autoFocus rows={6} value={text} onChange={e=>setText(e.target.value)} placeholder={t("feedbackPlaceholder")}/>
<div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={!text.trim()} onClick={async()=>{try{await navigator.clipboard.writeText(text.trim());setCopied(true)}catch{setCopied(false)}}}>{copied?t("copied"):t("copyContent")}</button></div>
</div></div>
}
2026-09-03 23:43:37 +00:00
2026-09-04 05:41:09 +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:t("loginFailed"))}finally{setBusy(false)}}}><Avatar name="L" size={58}/><h1>{t("loginTitle")}</h1><p>{t("loginDescription")}</p><label>{t("accessToken")}<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?t("verifying"):t("login")}</button></form></main>}