add voice

This commit is contained in:
王性驊 2026-09-06 13:39:06 +08:00
parent f8d00df017
commit fe95b37749
21 changed files with 1100 additions and 204 deletions

View File

@ -16,10 +16,10 @@ import visibility2 from "react-useanimations/lib/visibility2";
import searchToX from "react-useanimations/lib/searchToX";
import { api, ApiError } from "./api";
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";
import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types";
import { ChatMarkdown, CopyMessageButton } from "./markdown";
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, type CronPreset, type ScheduleItem } from "./schedule";
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule";
import { CallOverlay, PhoneIcon } from "./call";
import { VoiceSettingsDialog } from "./voice-settings";
@ -33,12 +33,43 @@ function readSessionStore():Record<string,string>{try{const raw=localStorage.get
function writeSessionStore(botId:string,sessionId:string){const store=readSessionStore();store[botId]=sessionId;localStorage.setItem(SESSION_STORE,JSON.stringify(store))}
function readPaneStore():{collapsed:boolean;part:RightPart}{try{const raw=localStorage.getItem(PANE_STORE);if(!raw)return{collapsed:false,part:"computer"};const value=JSON.parse(raw) as {collapsed?:boolean;part?:string};return{collapsed:Boolean(value.collapsed),part:value.part==="memory"||value.part==="settings"||value.part==="plugins"||value.part==="accounts"?value.part:"computer"}}catch{return{collapsed:false,part:"computer"}}}
type WorkspacePrefs={name:string;showHidden:boolean};
function 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}}}
function isDefaultWorkspaceName(name?:string|null){return !name||name==="Local workspace"||name==="本機工作區"}
function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:isDefaultWorkspaceName(name)?t("localWorkspace"):name||t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return <span className="workspace-avatar" aria-hidden="true">{initials}</span>}
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
function isTransitionStep(step?:string|null){return step==="電腦啟動中"||step==="喚醒中"||step==="換手中"}
function isTransitionStep(step?:string|null){return /電腦啟動中|喚醒中|換手中/.test(step||"")}
function localizeStepDetail(detail:string){
if(detail==="電腦啟動中"||detail==="電腦啟動中…")return t("hudBooting");
if(detail==="喚醒中"||detail==="喚醒中…")return t("hudWaking");
if(detail==="換手中"||detail==="換手中…")return t("hudHandoff");
if(detail==="思考中")return t("stepThinking");
if(detail==="看畫面")return t("stepWatchingScreen");
if(detail==="重新確認畫面")return t("stepRecheckingScreen");
if(detail==="填入已存帳號")return t("stepFillingLogin");
if(detail==="列出已存帳號")return t("stepListingAccounts");
if(detail==="列出排程")return t("stepListingSchedules");
if(detail==="取消排程")return t("stepCancelingSchedule");
if(detail.startsWith("讀取技能 "))return t("stepUsingSkill",{name:detail.slice("讀取技能 ".length)});
if(detail.startsWith("排程 "))return t("stepScheduling",{name:detail.slice("排程 ".length)});
return detail;
}
function localizeStep(step?:string|null){
if(!step)return "";
const cut=step.indexOf(": ");
if(cut>=0){
const head=step.slice(0,cut);
const tail=localizeStepDetail(step.slice(cut+2));
return `${head}: ${tail}`;
}
return localizeStepDetail(step);
}
function localizeError(message:string){
if(message==="示範進行中:先按「完成示範」或「取消」,再送訊息。")return t("teachInProgress");
if(message==="AI 回應逾時120 秒)")return t("aiTimeout");
return message;
}
function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean){
const step=computer.busyStep||"";
if(computer.state==="booting"||step==="電腦啟動中")return t("hudBooting");
@ -47,7 +78,7 @@ function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean)
if(connecting)return t("hudConnecting");
return null;
}
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)}
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();const loc=dateLocale();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat(loc,{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat(loc,{weekday:"long"}).format(date);return new Intl.DateTimeFormat(loc,{month:"numeric",day:"numeric"}).format(date)}
const ATTACH_MAX=4;
const ATTACH_MAX_BYTES=10*1024*1024;
const ATTACH_ACCEPT=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.md,.csv,.json,.html,.htm,.xml,.docx,.xlsx,.pptx,image/*,text/*,application/pdf";
@ -57,8 +88,9 @@ function readAsBase64(file:File){return new Promise<string>((resolve,reject)=>{c
function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`}
function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()}
function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})}
function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as {kind:string;site?:string;why?:string;name?:string;human?:string}[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;site?:string;why?:string;name?:string;human?:string};if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun")return [value];return []})}
function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===t("attachedFile",{name:file.name}))}
function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as {kind:string;site?:string;why?:string;name?:string;human?:string;cron?:string}[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;site?:string;why?:string;name?:string;human?:string;cron?:string};if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun")return [value];return []})}
function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===`Attached ${file.name}`||text===t("attachedFile",{name:file.name}))}
function isAutoScheduleCaption(body:string,chips:{kind?:string}[]){if(!chips.some(chip=>chip.kind==="schedule"||chip.kind==="scheduleRun"))return false;const text=body.trim();return /^\[排程(試跑)?\]/.test(text)||/^\[Schedule( test)?\]/i.test(text)}
function FileCard({file,preview,onRemove}:{file:{name:string;size?:number};preview?:string|null;onRemove?:()=>void}){const ext=fileExt(file.name);return <div className={`file-card ${onRemove?"is-pending":""}`}>{preview?<img className="file-card-thumb" src={preview} alt=""/>:<div className="file-card-badge" aria-hidden="true">{ext}</div>}<div className="file-card-meta"><strong>{file.name}</strong><small>{typeof file.size==="number"?formatBytes(file.size):ext}</small></div>{onRemove&&<button type="button" className="file-card-remove" title={t("attachRemove",{name:file.name})} onClick={onRemove}><X/></button>}</div>}
function clientNonce(){
const webCrypto=globalThis.crypto;
@ -73,6 +105,7 @@ function clientNonce(){
}
export function App(){
const locale=useLocale();
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[]>([]);
const [sessions,setSessions]=useState<Session[]>([]); const [activeSessionId,setActiveSessionId]=useState<string|null>(null);
const [messages,setMessages]=useState<Message[]>([]); const [computer,setComputer]=useState<ComputerStatus>(blankComputer);
@ -132,7 +165,7 @@ export function App(){
useEffect(()=>{setSlashIndex(0);setSlashDismissed(false)},[draft]);
const slashToken=draft.trimStart().split(/\s/,1)[0].slice(1).toLowerCase();
const slashSuggestions=!slashDismissed&&/^\/[^\s]*$/.test(draft.trimStart())
? [{name:"goal",description:t("goalCommandHint"),kind:"執行模式"},...fileSkills.map(skill=>({...skill,kind:"檔案技能"}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8)
? [{name:"goal",description:t("goalCommandHint"),kind:t("slashMode")},...fileSkills.map(skill=>({...skill,kind:t("slashFileSkill")}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8)
: [];
const loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]);
@ -153,17 +186,17 @@ export function App(){
if(refreshSeq!==refreshSeqRef.current)return;
setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills);setScreenUrl(status.botId===computerBot?screen.url:null)
},[activeId,activeRoomId,activeSessionId]);
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]);
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(localizeError(e.message))})},[loadBots]);
useEffect(()=>{if(!voiceSettings?.enabled)setCallOpen(false)},[voiceSettings?.enabled]);
useEffect(()=>{api<VoiceSettings>("/api/voice/settings").then(setVoiceSettings).catch(()=>setVoiceSettings(null))},[]);
useEffect(()=>{if(authRequired)return;api<FileSkill[]>("/api/file-skills").then(setFileSkills).catch(()=>setFileSkills([]))},[authRequired]);
useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]);
useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(e.message))},[loadSessions]);
useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(localizeError(e.message)))},[loadSessions]);
useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current="";setPendingFiles(current=>{current.forEach(file=>file.preview&&URL.revokeObjectURL(file.preview));return []})},[activeSessionId]);
useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]);
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]);
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]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus("剪貼簿已同步")).catch(()=>setClipboardStatus("瀏覽器未允許同步,請按複製按鈕"))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(localizeError(e.message)));const 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]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus(t("clipboardSynced"))).catch(()=>setClipboardStatus(t("clipboardSyncBlocked")))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
useEffect(()=>{setDesktopReady(false)},[screenUrl,paneBotId]);
useEffect(()=>{if(skipHandoffRef.current){skipHandoffRef.current=false;holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId;return}if((holderRef.current!==computer.controlHolder||paneBotRef.current!==paneBotId)&&computer.state==="running")setHandingOff(true);holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId},[computer.controlHolder,paneBotId,computer.state]);
useEffect(()=>{if(!handingOff)return;const timer=setTimeout(()=>setHandingOff(false),1600);return()=>clearTimeout(timer)},[handingOff]);
@ -171,6 +204,7 @@ export function App(){
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||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]);
useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
useEffect(()=>{setWorkspaceName(name=>isDefaultWorkspaceName(name)?t("localWorkspace"):name)},[locale]);
useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]);
useEffect(()=>{if(!sessionStoreKey||!activeSessionId)return;if(!sessions.some(session=>session.id===activeSessionId))return;if(!activeRoomId&&!sessions.some(session=>session.id===activeSessionId&&session.botId===activeId))return;writeSessionStore(sessionStoreKey,activeSessionId)},[sessionStoreKey,activeId,activeRoomId,activeSessionId,sessions]);
useEffect(()=>{localStorage.setItem(PANE_STORE,JSON.stringify({collapsed:rightCollapsed,part:rightPart}))},[rightCollapsed,rightPart]);
@ -194,7 +228,7 @@ export function App(){
if(scheduleDraft.id)await api(`/api/schedules/${scheduleDraft.id}`,{method:"PATCH",body:JSON.stringify(body)});
else await api(`/api/bots/${paneBot.id}/schedules`,{method:"POST",body:JSON.stringify(body)});
setScheduleDraft(null);await reloadSchedules();
}catch(e){setScheduleError(e instanceof Error?e.message:t("operationFailed"))}
}catch(e){setScheduleError(e instanceof Error?localizeError(e.message):t("operationFailed"))}
finally{setScheduleSaving(false)}
}
@ -202,7 +236,7 @@ export function App(){
const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
const chatName=activeRoom?.name||active?.name||"";
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 action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
async function stopChat(){if(activeSessionId)await api(`/api/sessions/${activeSessionId}/stop`,{method:"POST",body:"{}"});else if(active)await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});}
async function startTeaching(goal:string){if(!active)return;setTeachOpen(false);await action(async()=>{await api(`/api/bots/${active.id}/skills/start`,{method:"POST",body:JSON.stringify({goal})});setComputerOpen(true)})}
async function stopTeaching(){if(!active)return;await action(()=>api(`/api/bots/${active.id}/skills/stop`,{method:"POST",body:"{}"}));setComputerOpen(false)}
@ -213,32 +247,32 @@ export function App(){
async function updateSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(()=>api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook,save:true})}));setEditingSkillId(null)}
async function deleteSkill(skill:TaughtSkill){if(!window.confirm(t("deleteSkillConfirm",{name:skill.name||skill.goal})))return;await action(()=>api(`/api/skills/${skill.id}`,{method:"DELETE"}));setEditingSkillId(null)}
const editingSkill=editingSkillId?skills.find(skill=>skill.id===editingSkillId)||null:null;
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}執行「${skill.name}`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}${t("runSkillNamed",{name:skill.name})}`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
async function importSkillFile(file:File){if(!active)return;await action(async()=>{let payload:unknown;try{payload=JSON.parse(await file.text())}catch{throw new Error(t("skillImportInvalid"))}const skill=await api<TaughtSkill>(`/api/bots/${active.id}/skills/import`,{method:"POST",body:JSON.stringify(payload)});setEditingSkillId(skill.id)})}
function addPendingFiles(list:FileList|File[]){const incoming=[...list];if(!incoming.length)return;setError(null);setPendingFiles(current=>{const next=[...current];for(const file of incoming){if(next.length>=ATTACH_MAX){setError(t("attachTooMany"));break}if(file.size>ATTACH_MAX_BYTES){setError(t("attachTooLarge"));continue}if(!attachAllowed(file)){setError(t("attachType"));continue}next.push({id:clientNonce(),file,preview:file.type.startsWith("image/")?URL.createObjectURL(file):null})}return next})}
function removePendingFile(id:string){setPendingFiles(current=>current.filter(item=>{if(item.id===id&&item.preview)URL.revokeObjectURL(item.preview);return item.id!==id}))}
async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();const files=pendingFiles;if((!active&&!activeRoom)||!activeSessionId||(!text&&files.length===0))return;sendingRef.current=true;setDraft("");setPendingFiles([]);historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{try{const attachments=await Promise.all(files.map(async item=>({name:item.file.name,mimeType:item.file.type,content:await readAsBase64(item.file)})));await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce(),attachments})});sentHistoryRef.current.push(text||files[0]?.file.name||"");if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();files.forEach(item=>item.preview&&URL.revokeObjectURL(item.preview));await loadSessions()}catch(error){setPendingFiles(files);throw error}})}finally{sendingRef.current=false}}
function composerKeyDown(event:ReactKeyboardEvent<HTMLTextAreaElement>){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current<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()}}
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 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?localizeError(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"))}}
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?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?localizeError(e.message):t("rememberFailed"))}}
function pasteText(text:string){
const botId=paneBotId;
if(!botId||computer.controlHolder!=="user")return;
pasteQueueRef.current=pasteQueueRef.current.catch(()=>{}).then(async()=>{
if(currentPaneRef.current!==botId)return;
await api(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"clipboard",text})});
if(currentPaneRef.current===botId)setClipboardStatus("已貼上文字");
}).catch(()=>setClipboardStatus("貼上失敗,請確認已接管電腦後重試"));
if(currentPaneRef.current===botId)setClipboardStatus(t("clipboardPasted"));
}).catch(()=>setClipboardStatus(t("clipboardPasteFailed")));
}
async function copySelection(){
const botId=paneBotId;if(!botId)return;
try{const result=await api<{text:string}>(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"copy"})});
if(currentPaneRef.current!==botId)return;
setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus("已複製選取文字");
}catch{setClipboardStatus("複製未同步;請按複製按鈕,或在遠端使用 Ctrl+Shift+C");}
setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus(t("clipboardCopiedSelection"));
}catch{setClipboardStatus(t("clipboardCopyFailed"));}
}
async function pasteClipboard(){try{pasteText(await navigator.clipboard.readText())}catch{setClipboardOpen(true)}}
async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}}
@ -276,11 +310,11 @@ export function App(){
<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>;
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"))}}}/>;
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?localizeError(e.message):t("loginFailed"))}}}/>;
return <AvatarLookProvider value={looks}><div className={`app-shell ${rightCollapsed?"right-collapsed":"right-open"}`}>
<aside className={`sidebar ${mobileNav?"open":""}`}>
<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>
<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="var(--ink)" 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>
<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>}
@ -295,6 +329,7 @@ export function App(){
<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={()=>setLocale(getLocale()==="zh-TW"?"en":"zh-TW")}>{t("language")}: {getLocale()==="zh-TW"?t("languageEn"):t("languageZh")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("settings")}><Settings/>{t("settings")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("model")}><BotIcon/>{t("modelSettings")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("voice")}><PhoneIcon/>{t("voiceSettings")}</button>
@ -313,14 +348,14 @@ export function App(){
</aside>
<main className="chat-panel">
<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} size={32} 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>
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="var(--ink)"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} size={32} 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>
{callOpen&&voiceSettings?.enabled&&active&&activeSessionId&&!activeRoomId?<CallOverlay bot={active} sessionId={activeSessionId} takeover={computer.takeoverRequested||computer.controlHolder==="user"} onHangUp={()=>setCallOpen(false)} onTakeOver={()=>{setRightPart("computer");setRightCollapsed(false);if(active)void action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}}/>:null}
<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;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{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>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>)}{!hideBody&&(message.role==="assistant"?<div className="message-stack"><div className="message-body md"><ChatMarkdown>{message.body}</ChatMarkdown></div>{message.body.trim()?<CopyMessageButton text={message.body}/>:null}</div>:<span className="message-body">{message.body}</span>)}{!hideBody&&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>})}{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)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
<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:listJoin(activeRoom.members.map(member=>member.name))}):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;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files)||isAutoScheduleCaption(message.body,chips);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{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>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{scheduleWhen(chip.cron,chip.human)}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{scheduleWhen(chip.cron,chip.human)}</small></div>)}{!hideBody&&(message.role==="assistant"?<div className="message-stack"><div className="message-body md"><ChatMarkdown>{message.body}</ChatMarkdown></div>{message.body.trim()?<CopyMessageButton text={message.body}/>:null}</div>:<span className="message-body">{message.body}</span>)}{!hideBody&&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>})}{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)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
<div className={`composer-dock ${statusMembers.length?"has-status":""}`}>
{statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return <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-copy"><span className="working-label">{label}</span>{!transition&&step?<span className="working-step">{step}</span>:null}</span></div>})}
<form className={`composer ${pendingFiles.length?"has-files":""}`} onSubmit={send} onDragOver={event=>{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}><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={!activeSessionId||Boolean(teaching)} title={t("attachFileHint")} onClick={()=>{setPlusOpen(false);attachRef.current?.click()}}><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><button type="button" role="menuitem" disabled={!active} title={t("importSkillHint")} onClick={()=>{setPlusOpen(false);importRef.current?.click()}}><Upload/>{t("importSkill")}</button>{savedSkills.length>0&&<><hr/><div className="plus-menu-skills"><small className="plus-menu-label">{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}</small>{savedSkills.length>=6&&<input className="plus-menu-search" value={skillQuery} onChange={e=>setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}<div className="plus-menu-skill-list">{listedSkills.map(skill=><div className="plus-menu-skill" key={skill.id}><button type="button" role="menuitem" title={t("runSkillNamed",{name:skill.name})+(skill.playbook.whenToUse?`\n${skill.playbook.whenToUse}`:"")} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button><button type="button" className="skill-edit" title={t("exportSkillHint")} aria-label={t("exportSkill")} onClick={()=>downloadSkill(skill.name,skill.goal,skill.playbook)}><Download/></button><button type="button" className="skill-edit" title={t("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}{listedSkills.length===0&&<small className="plus-menu-empty">{t("noMatchingSkills")}</small>}</div></div></>}</div>}</div><input ref={importRef} className="skill-import-input" type="file" accept="application/json,.json" tabIndex={-1} aria-hidden="true" onChange={event=>{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/><input ref={attachRef} className="skill-import-input attach-input" type="file" multiple accept={ATTACH_ACCEPT} tabIndex={-1} aria-hidden="true" onChange={event=>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&<div className="composer-files">{pendingFiles.map(item=><FileCard key={item.id} file={{name:item.file.name,size:item.file.size}} preview={item.preview} onRemove={()=>removePendingFile(item.id)}/>)}</div>}{slashSuggestions.length>0&&<div className="slash-suggestions" role="listbox" aria-label="指令與技能">{slashSuggestions.map((skill,index)=><button type="button" role="option" aria-selected={index===slashIndex} key={skill.name} onClick={()=>{setDraft(`/${skill.name} `);requestAnimationFrame(()=>document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus())}}><strong>/{skill.name}</strong><small>{skill.kind}</small><span>{skill.description}</span></button>)}</div>}<textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} onPaste={event=>{const files=event.clipboardData?.files;if(files&&files.length){event.preventDefault();addPendingFiles(files)}}} 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()&&pendingFiles.length===0)||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
{statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return <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-copy"><span className="working-label">{label}</span>{!transition&&step?<span className="working-step">{localizeStep(step)}</span>:null}</span></div>})}
<form className={`composer ${pendingFiles.length?"has-files":""}`} onSubmit={send} onDragOver={event=>{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}><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={!activeSessionId||Boolean(teaching)} title={t("attachFileHint")} onClick={()=>{setPlusOpen(false);attachRef.current?.click()}}><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><button type="button" role="menuitem" disabled={!active} title={t("importSkillHint")} onClick={()=>{setPlusOpen(false);importRef.current?.click()}}><Upload/>{t("importSkill")}</button>{savedSkills.length>0&&<><hr/><div className="plus-menu-skills"><small className="plus-menu-label">{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}</small>{savedSkills.length>=6&&<input className="plus-menu-search" value={skillQuery} onChange={e=>setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}<div className="plus-menu-skill-list">{listedSkills.map(skill=><div className="plus-menu-skill" key={skill.id}><button type="button" role="menuitem" title={t("runSkillNamed",{name:skill.name})+(skill.playbook.whenToUse?`\n${skill.playbook.whenToUse}`:"")} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button><button type="button" className="skill-edit" title={t("exportSkillHint")} aria-label={t("exportSkill")} onClick={()=>downloadSkill(skill.name,skill.goal,skill.playbook)}><Download/></button><button type="button" className="skill-edit" title={t("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}{listedSkills.length===0&&<small className="plus-menu-empty">{t("noMatchingSkills")}</small>}</div></div></>}</div>}</div><input ref={importRef} className="skill-import-input" type="file" accept="application/json,.json" tabIndex={-1} aria-hidden="true" onChange={event=>{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/><input ref={attachRef} className="skill-import-input attach-input" type="file" multiple accept={ATTACH_ACCEPT} tabIndex={-1} aria-hidden="true" onChange={event=>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&<div className="composer-files">{pendingFiles.map(item=><FileCard key={item.id} file={{name:item.file.name,size:item.file.size}} preview={item.preview} onRemove={()=>removePendingFile(item.id)}/>)}</div>}{slashSuggestions.length>0&&<div className="slash-suggestions" role="listbox" aria-label={t("slashList")}>{slashSuggestions.map((skill,index)=><button type="button" role="option" aria-selected={index===slashIndex} key={skill.name} onClick={()=>{setDraft(`/${skill.name} `);requestAnimationFrame(()=>document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus())}}><strong>/{skill.name}</strong><small>{skill.kind}</small><span>{skill.description}</span></button>)}</div>}<textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} onPaste={event=>{const files=event.clipboardData?.files;if(files&&files.length){event.preventDefault();addPendingFiles(files)}}} 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()&&pendingFiles.length===0)||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)"/></button>}</form>
</div>
</main>
@ -345,7 +380,7 @@ export function App(){
</>
</aside>}
{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.usingComputer?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} onStop={stopComputer}/>}<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" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen"><div className="overlay-desktop">{frame}{hud}</div></div>{error&&<div className="overlay-error">{error}</div>}</div>}
{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.usingComputer?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} onStop={stopComputer}/>}<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="var(--ink)"/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen"><div className="overlay-desktop">{frame}{hud}</div></div>{error&&<div className="overlay-error">{error}</div>}</div>}
{teachOpen&&active&&<TeachDialog bot={active} busy={busy} close={()=>setTeachOpen(false)} start={goal=>void startTeaching(goal)}/>}
{editingSkill&&<SkillEditDialog key={editingSkill.id} skill={editingSkill} busy={busy} close={()=>setEditingSkillId(null)} save={(name,playbook)=>void updateSkill(editingSkill,name,playbook)} test={(name,playbook)=>void testSkill(editingSkill,name,playbook)} remove={()=>void deleteSkill(editingSkill)} exportFile={(name,playbook)=>downloadSkill(name,editingSkill.goal,playbook)}/>}
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{pasteText(text);setClipboardOpen(false)}}/>}
@ -368,7 +403,7 @@ export function App(){
</div></AvatarLookProvider>
}
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 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="var(--ink)"/>{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="var(--ink)"/>{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="var(--ink)"/>:<UseAnimations animation={visibility2} size={15} strokeColor="var(--ink)"/>}{bot.hidden?t("unhide"):t("hide")}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><UseAnimations animation={trash2} size={15} strokeColor="var(--danger-soft)"/>{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);
@ -385,7 +420,7 @@ function SessionMenu({sessions,activeSessionId,open,setOpen,busy,onSelect,onCrea
<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>
<button type="button" className="icon-button" title={t("deleteConversation")} disabled={busy} onClick={()=>onDelete(session.id)}><UseAnimations animation={trash2} size={18} strokeColor="var(--ink)"/></button>
</div>)}</div>
<hr/>
<button type="button" className="danger-item" disabled={busy||!activeSessionId} onClick={onClear}>{t("clearConversation")}</button>
@ -405,8 +440,8 @@ const EXPR_I18N = {idle:"exprIdle",happy:"exprHappy",sad:"exprSad",mad:"exprMad"
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]);
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(listJoin(bot.tags||[]));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(listJoin(bot.tags||[]))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags,look.expression,look.background]);
const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];
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)}}}>
@ -428,8 +463,8 @@ function VaultPane({bot}:{bot:Bot}){
const[items,setItems]=useState<Account[]>([]);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
const[form,setForm]=useState({site:"",host:"",username:"",password:"",notes:""});
const load=useCallback(()=>api<Account[]>(`/api/bots/${bot.id}/accounts`).then(setItems),[bot.id]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
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)}}
useEffect(()=>{load().catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[load]);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
function setField<K extends keyof typeof form>(key:K,value:string){setForm(current=>({...current,[key]:value}))}
return <div className="vault-pane">
<p className="memory-help">{t("accountsHelp")}</p>
@ -443,7 +478,7 @@ function VaultPane({bot}:{bot:Bot}){
<label>{t("accountNotes")}<input value={form.notes} onChange={e=>setField("notes",e.target.value)} placeholder={t("accountNotesPlaceholder")} autoComplete="off"/></label>
<button className="primary" disabled={busy||!form.site.trim()||!form.username.trim()||!form.password}>{t("addAccount")}</button>
</form>
<div className="account-list">{items.length===0?<p className="vault-empty">{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><div className="account-row-main"><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small>{item.notes?<small className="account-notes">{item.notes}</small>:null}</div><button type="button" className="icon-button" title={t("delete")} aria-label={t("delete")} disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button></div>)}</div>
<div className="account-list">{items.length===0?<p className="vault-empty">{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><div className="account-row-main"><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small>{item.notes?<small className="account-notes">{item.notes}</small>:null}</div><button type="button" className="icon-button" title={t("delete")} aria-label={t("delete")} disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}><UseAnimations animation={trash2} size={18} strokeColor="var(--ink)"/></button></div>)}</div>
{error&&<div className="pane-error">{error}</div>}
</div>
}
@ -452,11 +487,11 @@ function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
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);
const load=useCallback(()=>api<MemoryItem[]>(`/api/bots/${bot.id}/memories`).then(setItems),[bot.id]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?localizeError(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"))}}
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?localizeError(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?localizeError(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>
@ -486,7 +521,7 @@ function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
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>}){
const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null);
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)}}
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
return <div className="mcp-pane">
<p className="memory-help">{t("mcpHelp")}</p>
<button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button>
@ -517,7 +552,7 @@ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;
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]);
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?localizeError(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>={};
@ -528,7 +563,7 @@ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;
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"))}
}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}
finally{setBusy(false)}
}
function choose(entry:McpCatalogEntry){
@ -545,7 +580,7 @@ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;
{[...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)}}}/>:<>
</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?localizeError(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)}>
@ -582,7 +617,8 @@ function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;o
}
function ComputerHud({bot,label}:{bot:{id:string;name:string;avatarColor?:string;avatarShape?:AvatarShape};label:string}){
return <div className="computer-hud" role="status" aria-label={`${bot.name}${label}`}><span className="computer-signal" aria-hidden="true"><span className="computer-signal-face"><i/><i/></span></span><span className="computer-hud-label">{label}</span></div>
const sep=getLocale()==="en"?": ":"";
return <div className="computer-hud" role="status" aria-label={`${bot.name}${sep}${label}`}><span className="computer-signal" aria-hidden="true"><span className="computer-signal-face"><i/><i/></span></span><span className="computer-hud-label">{label}</span></div>
}
function EmptyComputer({state}:{state:ComputerStatus["state"]}){if(state==="booting"||state==="suspended")return <div className="empty-computer is-waiting" aria-hidden="true"/>;return <div className="empty-computer"><Computer/><strong>{stateLabel(state)}</strong><span>{t("computerPreviewHint")}</span></div>}
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart,onStop}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>;onStop?:()=>Promise<void>}){
@ -615,7 +651,7 @@ function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart,
</div>}
</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>;onStop?:()=>Promise<void>}){const interactive=props.computer.controlHolder==="user";return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" disabled={!interactive} title={t("pasteClipboard")} onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" disabled={!interactive} title={t("copyDesktopClipboard")} onClick={props.copy}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button></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>;onStop?:()=>Promise<void>}){const interactive=props.computer.controlHolder==="user";return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" disabled={!interactive} title={t("pasteClipboard")} onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" disabled={!interactive} title={t("copyDesktopClipboard")} onClick={props.copy}><UseAnimations animation={copy} size={18} strokeColor="var(--ink)"/></button></div>}
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())}}>
@ -650,8 +686,12 @@ function SkillDraftCard({skill,busy,onSave,onTest,onDiscard,onEdit,onExport}:{sk
const lines=(text:string)=>text.split("\n").map(line=>line.replace(/^\s*(?:\d+[.、)]|[-•*])\s*/,"").trim()).filter(Boolean);
function stepsToText(steps:(PlaybookStep|string)[]|undefined){return (steps||[]).map(step=>{if(typeof step==="string")return step;return step.expect?`${step.do}${step.expect}`:step.do}).join("\n")}
function textToSteps(text:string,previous:(PlaybookStep|string)[]|undefined):PlaybookStep[]{const old=(previous||[]).filter((step):step is PlaybookStep=>typeof step==="object"&&step!==null);return lines(text).map(line=>{const [doPart,...rest]=line.split(/\s*(?:→|->|=>)\s*/);const expect=rest.join(" → ").trim();const match=old.find(step=>step.do===doPart.trim());return {do:doPart.trim(),expect:expect||(match&&!rest.length?match.expect:undefined)||"",note:match?.note||""}})}
function inputsToText(inputs:PlaybookInput[]|undefined){return (inputs||[]).map(input=>`${input.name}${input.description?`${input.description}`:""}${input.example?`|例:${input.example}`:""}`).join("\n")}
function textToInputs(text:string):PlaybookInput[]{return lines(text).map(line=>{const [head,...exampleParts]=line.split(/\s*[|]\s*/);const example=exampleParts.join("").replace(/^例[:]\s*/,"").trim();const [name,...descParts]=head.split(/[:]/);return {name:name.trim(),description:descParts.join("").trim()||undefined,example:example||undefined}}).filter(input=>input.name)}
function inputsToText(inputs:PlaybookInput[]|undefined){
const colon=getLocale()==="en"?": ":"";
const exampleMark=getLocale()==="en"?" | e.g. ":"|例:";
return (inputs||[]).map(input=>`${input.name}${input.description?`${colon}${input.description}`:""}${input.example?`${exampleMark}${input.example}`:""}`).join("\n");
}
function textToInputs(text:string):PlaybookInput[]{return lines(text).map(line=>{const [head,...exampleParts]=line.split(/\s*[|]\s*/);const example=exampleParts.join("").replace(/^(?:例[:]|e\.g\.?:?)\s*/i,"").trim();const [name,...descParts]=head.split(/[:]/);return {name:name.trim(),description:descParts.join(getLocale()==="en"?": ":"").trim()||undefined,example:example||undefined}}).filter(input=>input.name)}
function SkillEditDialog({skill,busy,close,save,test,remove,exportFile}:{skill:TaughtSkill;busy:boolean;close:()=>void;save:(name:string,playbook:Playbook)=>void;test:(name:string,playbook:Playbook)=>void;remove:()=>void;exportFile:(name:string,playbook:Playbook)=>void}){
const p=skill.playbook;
const [name,setName]=useState(skill.name||p.name||"");
@ -684,7 +724,7 @@ function SkillEditDialog({skill,busy,close,save,test,remove,exportFile}:{skill:T
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>}
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>}
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 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="var(--danger-soft)"/>{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>}
@ -706,6 +746,13 @@ function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCol
<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>
<label>{t("language")}
<select value={getLocale()} onChange={e=>setLocale(e.target.value==="en"?"en":"zh-TW")}>
<option value="zh-TW">{t("languageZh")}</option>
<option value="en">{t("languageEn")}</option>
</select>
</label>
<p className="dialog-lead">{t("languageHint")}</p>
<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>
@ -721,7 +768,7 @@ function ModelSettingsDialog({close}:{close:()=>void}){
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")))},[]);
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?localizeError(e.message):t("loadFailed")))},[]);
async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
try{
@ -745,7 +792,7 @@ function ModelSettingsDialog({close}:{close:()=>void}){
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)}}}>
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?localizeError(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>
@ -789,6 +836,7 @@ function HelpDialog({close}:{close:()=>void}){
<section><h3>{t("helpSkillsTitle")}</h3><p>{t("helpSkills")}</p></section>
<section><h3>{t("helpAttachTitle")}</h3><p>{t("helpAttach")}</p></section>
<section><h3>{t("helpVoiceTitle")}</h3><p>{t("helpVoice")}</p></section>
<section><h3>{t("helpLanguageTitle")}</h3><p>{t("helpLanguage")}</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>
@ -803,4 +851,14 @@ function FeedbackDialog({close}:{close:()=>void}){
</div></div>
}
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>}
function LoginScreen({authenticated}:{authenticated:()=>void}){
useLocale();
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?localizeError(err.message):t("loginFailed"))}finally{setBusy(false)}}}>
<div className="login-lang">
<button type="button" className={getLocale()==="zh-TW"?"picked":""} onClick={()=>setLocale("zh-TW")}>{t("languageZh")}</button>
<button type="button" className={getLocale()==="en"?"picked":""} onClick={()=>setLocale("en")}>{t("languageEn")}</button>
</div>
<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>;
}

View File

@ -9,21 +9,21 @@
.avatar-editor{display:grid;justify-items:center;gap:5px;padding:18px 0 2px}
.avatar-editor strong{margin-top:7px}
.avatar-editor .avatar.blobatar{margin-bottom:2px}
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side,#0b0b0c);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
.bot-row .avatar-wrap::after{content:none;position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side);border-radius:50%;background:var(--online);box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
.bot-row .avatar-wrap::after{content:none;position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:var(--online);box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
.avatar.blobatar.thinking{animation:avatar-rainbow-glow 1.8s linear infinite}
.avatar-wrap{transition:transform .16s ease}
.avatar-stack{position:relative;display:block;flex:0 0 auto}
.avatar-stack .stack-item{position:absolute;top:0}
.avatar-stack .stack-item .avatar{box-shadow:none;filter:drop-shadow(2px 0 0 var(--side,#0b0b0c)) drop-shadow(-2px 0 0 var(--side,#0b0b0c)) drop-shadow(0 2px 0 var(--side,#0b0b0c)) drop-shadow(0 -2px 0 var(--side,#0b0b0c))}
.avatar-stack .stack-item .avatar{box-shadow:none;filter:drop-shadow(2px 0 0 var(--side)) drop-shadow(-2px 0 0 var(--side)) drop-shadow(0 2px 0 var(--side)) drop-shadow(0 -2px 0 var(--side))}
.avatar-stack .stack-item .avatar.thinking{filter:none;animation:avatar-rainbow-glow 1.8s linear infinite}
.stack-extra{position:absolute;top:0;display:grid;place-items:center;border-radius:50%;background:#2a2a2e;color:var(--muted);font-size:11px;font-weight:650;box-shadow:0 0 0 2px var(--side,#0b0b0c)}
.stack-extra{position:absolute;top:0;display:grid;place-items:center;border-radius:50%;background:#2a2a2e;color:var(--muted);font-size:11px;font-weight:650;box-shadow:0 0 0 2px var(--side)}
.topbar .avatar-stack{margin-right:2px}
.avatar-stack{isolation:isolate}
.avatar-stack .stack-item{transition:transform .16s ease}
.avatar-stack .stack-item:hover{transform:translateY(-2px);z-index:8!important}
.avatar-stack .stack-item .avatar{filter:drop-shadow(2px 0 0 var(--main,#0d0d0e)) drop-shadow(-2px 0 0 var(--main,#0d0d0e)) drop-shadow(0 2px 0 var(--main,#0d0d0e)) drop-shadow(0 -2px 0 var(--main,#0d0d0e))}
.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main,#0d0d0e)}
.avatar-stack .stack-item .avatar{filter:drop-shadow(2px 0 0 var(--main)) drop-shadow(-2px 0 0 var(--main)) drop-shadow(0 2px 0 var(--main)) drop-shadow(0 -2px 0 var(--main))}
.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main)}
.avatar-stack .stack-item .avatar.thinking{filter:none}
/* Keep avatars independent of message and toolbar dimensions. */
.avatar.blobatar{min-width:var(--avatar-size);max-width:var(--avatar-size);min-height:var(--avatar-size);padding:0;line-height:0;vertical-align:middle}

View File

@ -45,9 +45,16 @@ class CaptureProcessor extends AudioWorkletProcessor {
registerProcessor("lazyboy-capture", CaptureProcessor);
`;
// One frame per 128-sample worklet callback would be ~375 websocket messages a
// second; batching to 40 ms keeps latency low without flooding the provider.
export const CAPTURE_CHUNK_SAMPLES = VOICE_SAMPLE_RATE / 25;
// Cushion so a late chunk does not have to start in the past and click.
const PLAYBACK_LEAD_SECONDS = 0.08;
export type CallAudioHandlers = {
onCapture: (pcm: Uint8Array) => void;
onError: (message: string) => void;
onPlaybackChange?: (playing: boolean) => void;
};
export class CallAudio {
@ -55,14 +62,47 @@ export class CallAudio {
private context: AudioContext | null = null;
private stream: MediaStream | null = null;
private worklet: AudioWorkletNode | null = null;
private monitor: GainNode | null = null;
private nextTime = 0;
private playing: AudioBufferSourceNode[] = [];
private playbackActive = false;
private pending: Float32Array[] = [];
private pendingLength = 0;
private carry: Uint8Array | null = null;
private handlers: CallAudioHandlers;
constructor(handlers: CallAudioHandlers) {
this.handlers = handlers;
}
private queueCapture(samples: Float32Array): void {
if (samples.length) {
this.pending.push(samples);
this.pendingLength += samples.length;
}
while (this.pendingLength >= CAPTURE_CHUNK_SAMPLES) {
const chunk = new Float32Array(CAPTURE_CHUNK_SAMPLES);
let filled = 0;
while (filled < CAPTURE_CHUNK_SAMPLES) {
const head = this.pending[0] as Float32Array;
const take = Math.min(head.length, CAPTURE_CHUNK_SAMPLES - filled);
chunk.set(head.subarray(0, take), filled);
filled += take;
if (take === head.length) this.pending.shift();
else this.pending[0] = head.subarray(take);
}
this.pendingLength -= CAPTURE_CHUNK_SAMPLES;
this.handlers.onCapture(floatToPcm16(chunk));
}
}
private notifyPlayback(): void {
const active = this.playing.length > 0;
if (active === this.playbackActive) return;
this.playbackActive = active;
this.handlers.onPlaybackChange?.(active);
}
async start(): Promise<void> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
@ -87,47 +127,83 @@ export class CallAudio {
const worklet = new AudioWorkletNode(context, "lazyboy-capture");
worklet.port.onmessage = (event) => {
const samples = event.data as Float32Array;
const resampled = resample(samples, context.sampleRate, VOICE_SAMPLE_RATE);
this.handlers.onCapture(floatToPcm16(resampled));
this.queueCapture(resample(samples, context.sampleRate, VOICE_SAMPLE_RATE));
};
worklet.onprocessorerror = () => this.handlers.onError("capture stopped");
// Muted sink: the processor emits no output, and a zero gain guarantees the
// microphone can never leak back into the speakers.
const monitor = context.createGain();
monitor.gain.value = 0;
source.connect(worklet);
worklet.connect(context.destination);
worklet.connect(monitor);
monitor.connect(context.destination);
this.worklet = worklet;
this.monitor = monitor;
this.nextTime = context.currentTime;
}
play(pcm: ArrayBuffer): void {
const context = this.context;
if (!context) return;
const samples = resample(pcm16ToFloat(pcm), VOICE_SAMPLE_RATE, context.sampleRate);
// PCM16 frames can be split mid-sample across websocket messages; dropping
// the odd tail byte would shift every later sample and turn speech to noise.
let bytes = new Uint8Array(pcm);
if (this.carry) {
const joined = new Uint8Array(this.carry.length + bytes.length);
joined.set(this.carry, 0);
joined.set(bytes, this.carry.length);
bytes = joined;
this.carry = null;
}
if (bytes.length % 2 === 1) {
this.carry = bytes.slice(bytes.length - 1);
bytes = bytes.subarray(0, bytes.length - 1);
}
if (!bytes.length) return;
const aligned = bytes.slice().buffer;
const samples = resample(pcm16ToFloat(aligned), VOICE_SAMPLE_RATE, context.sampleRate);
if (!samples.length) return;
try {
const buffer = context.createBuffer(1, samples.length, context.sampleRate);
buffer.getChannelData(0).set(samples);
const node = context.createBufferSource();
node.buffer = buffer;
node.connect(context.destination);
const startAt = Math.max(context.currentTime, this.nextTime);
if (this.nextTime < context.currentTime) this.nextTime = context.currentTime + PLAYBACK_LEAD_SECONDS;
const startAt = this.nextTime;
node.start(startAt);
this.nextTime = startAt + buffer.duration;
this.playing.push(node);
node.onended = () => {
this.playing = this.playing.filter((item) => item !== node);
this.notifyPlayback();
};
this.notifyPlayback();
} catch {
this.handlers.onError("playback failed");
}
}
interrupt(): void {
for (const node of this.playing) {
node.onended = null;
try { node.stop(); } catch { /* already stopped */ }
}
this.playing = [];
this.carry = null;
if (this.context) this.nextTime = this.context.currentTime;
this.notifyPlayback();
}
stop(): void {
this.stopped = true;
this.interrupt();
this.pending = [];
this.pendingLength = 0;
this.worklet?.disconnect();
this.worklet = null;
this.monitor?.disconnect();
this.monitor = null;
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
void this.context?.close();

View File

@ -1,10 +1,11 @@
.call-overlay{position:absolute;inset:0;z-index:40;display:grid;place-items:center;background:rgba(5,5,6,.82);padding:24px}
.call-card{width:min(420px,100%);display:flex;flex-direction:column;align-items:center;gap:10px;padding:28px 24px 22px;border:1px solid var(--border);border-radius:28px;background:#121214;box-shadow:0 30px 80px rgba(0,0,0,.55);text-align:center}
.call-card{width:min(420px,100%);display:flex;flex-direction:column;align-items:center;gap:10px;padding:28px 24px 22px;border:1px solid var(--border);border-radius:28px;background:var(--elevated);box-shadow:0 30px 80px rgba(0,0,0,.55);text-align:center}
.call-kicker{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
.call-name{margin-top:4px;font-size:22px}
.call-phase{min-height:1.4em;color:var(--accent);font-size:14px}
.call-phase.working{color:#ffe08a}
.call-caption{min-height:3.2em;margin:4px 0 0;color:var(--muted);font-size:14.5px;line-height:1.5}
.call-caption.assistant{color:var(--ink)}
.call-error{margin:0;color:var(--danger);font-size:13px}
.call-time{color:var(--faint);font-variant-numeric:tabular-nums;font-size:13px}
.call-actions{display:flex;gap:10px;margin-top:8px}

View File

@ -23,12 +23,14 @@ function phaseLabel(phase: CallPhase, computer: ComputerEvent | null): string {
if (computer?.takeover) return t("takeOverToContinue");
if (phase === "connecting") return t("callConnecting");
if (phase === "speaking") return t("speaking");
if (phase === "working" || computer?.status === "running" || computer?.status === "queued") {
return t("workingOnComputer");
}
if (phase === "working") return t("workingOnComputer");
return t("listening");
}
function computerIsBusy(computer: ComputerEvent | null): boolean {
return computer?.status === "running" || computer?.status === "queued" || Boolean(computer?.takeover);
}
export function PhoneIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
@ -50,8 +52,9 @@ export function CallOverlay({
onHangUp: () => void;
onTakeOver: () => void;
}) {
const [phase, setPhase] = useState<CallPhase>("connecting");
const [caption, setCaption] = useState("");
const [connected, setConnected] = useState(false);
const [speaking, setSpeaking] = useState(false);
const [caption, setCaption] = useState<{ role: "user" | "assistant"; text: string } | null>(null);
const [error, setError] = useState("");
const [elapsed, setElapsed] = useState(0);
const [computer, setComputer] = useState<ComputerEvent | null>(null);
@ -70,7 +73,11 @@ export function CallOverlay({
function interrupt() {
audioRef.current?.interrupt();
setPhase("listening");
// Stopping local playback alone lets the model keep streaming the rest of
// its answer, so the provider has to be told to abandon the response too.
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "interrupt" }));
}
}
useEffect(() => {
@ -82,7 +89,8 @@ export function CallOverlay({
onCapture: (pcm) => {
if (socketRef.current?.readyState === WebSocket.OPEN) socketRef.current.send(pcm);
},
onError: (message) => setError(message),
onError: () => setError(t("callAudioFailed")),
onPlaybackChange: setSpeaking,
});
audioRef.current = audio;
const socket = new WebSocket(wsUrl(sessionId));
@ -95,7 +103,7 @@ export function CallOverlay({
socket.close();
setError(err instanceof Error && /NotAllowedError|PermissionDenied/.test(err.name + err.message) ? t("micDenied") : t("micFailed"));
});
setPhase("listening");
setConnected(true);
};
socket.onmessage = (event) => {
if (typeof event.data !== "string") {
@ -105,20 +113,15 @@ export function CallOverlay({
let payload: ServerEvent;
try { payload = JSON.parse(event.data) as ServerEvent; } catch { return; }
if (payload.type === "speech") {
if (payload.state === "started") {
audio.interrupt();
setPhase("listening");
} else setPhase("listening");
if (payload.state === "started") audio.interrupt();
return;
}
if (payload.type === "transcript") {
setCaption(payload.text);
if (payload.role === "assistant") setPhase(payload.final ? "listening" : "speaking");
setCaption({ role: payload.role, text: payload.text });
return;
}
if (payload.type === "computer") {
setComputer(payload);
if (payload.status === "running" || payload.status === "queued" || payload.takeover) setPhase("working");
return;
}
if (payload.type === "error") setError(payload.message);
@ -149,6 +152,13 @@ export function CallOverlay({
const minutes = Math.floor(elapsed / 60);
const seconds = String(elapsed % 60).padStart(2, "0");
const showTakeover = takeover || Boolean(computer?.takeover);
const phase: CallPhase = !connected
? "connecting"
: speaking
? "speaking"
: computerIsBusy(computer)
? "working"
: "listening";
return (
<div className="call-overlay" role="dialog" aria-label={t("call")}>
@ -157,7 +167,7 @@ export function CallOverlay({
<Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape as AvatarShape} active online size={72} />
<strong className="call-name">{bot.name}</strong>
<div className={`call-phase ${phase}`}>{phaseLabel(phase, computer)}</div>
<p className="call-caption">{caption || t("callPrompt")}</p>
<p className={`call-caption ${caption ? caption.role : ""}`}>{caption?.text || t("callPrompt")}</p>
{error ? <p className="call-error">{error}</p> : null}
<div className="call-time">{minutes}:{seconds}</div>
<div className="call-actions">

View File

@ -3,7 +3,7 @@
.message>.message-body,.message-stack>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
.message.user{justify-content:flex-end}
.message.user>.message-body{background:var(--cream);color:#1a1a1a}
.message.user>.message-body{background:var(--cream);color:var(--on-cream)}
.message.assistant>.message-body,.message-stack>.message-body{background:#19191c}
.message-stack{display:flex;flex-direction:column;align-items:flex-start;max-width:76%;min-width:0}
.message-stack>.message-body{max-width:100%}
@ -11,7 +11,7 @@
.copy-msg svg{flex:0 0 14px}
.copy-msg:hover{background:rgba(255,255,255,.07);color:var(--ink)}
.copy-msg.copied,.copy-msg.copied:hover{color:var(--accent);background:transparent;cursor:default}
.composer{position:absolute;left:max(34px,5vw);right:max(34px,5vw);bottom:22px;display:flex;align-items:flex-end;gap:8px;border:1px solid var(--border);background:#121214;border-radius:26px;padding:8px;box-shadow:0 16px 50px rgba(0,0,0,.22)}
.composer{position:absolute;left:max(34px,5vw);right:max(34px,5vw);bottom:22px;display:flex;align-items:flex-end;gap:8px;border:1px solid var(--border);background:var(--elevated);border-radius:26px;padding:8px;box-shadow:0 16px 50px rgba(0,0,0,.22)}
.composer textarea{flex:1;min-height:42px;max-height:140px;resize:none;border:0;outline:0;background:transparent;color:var(--ink);padding:11px 4px}
.composer-plus{background:transparent;color:var(--muted)}
.composer svg{width:18px}
@ -30,15 +30,15 @@
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-height:62px;align-items:center;padding:9px 10px;transform:none}
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
.slash-suggestions{position:absolute;left:58px;right:58px;bottom:calc(100% + 8px);z-index:8;display:grid;gap:2px;padding:6px;border:1px solid var(--border);border-radius:14px;background:#18181b;box-shadow:0 14px 34px rgba(0,0,0,.35)}
.slash-suggestions{position:absolute;left:58px;right:58px;bottom:calc(100% + 8px);z-index:8;display:grid;gap:2px;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35)}
.slash-suggestions button{display:flex;align-items:baseline;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:9px;background:transparent;color:var(--ink);text-align:left;cursor:pointer}
.slash-suggestions button:hover,.slash-suggestions button:focus-visible{background:rgba(255,255,255,.08);outline:0}
.slash-suggestions strong{min-width:110px;color:var(--accent);font-weight:650}
.slash-suggestions span{overflow:hidden;color:var(--muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}
.composer-files{display:flex;flex-wrap:wrap;gap:8px;flex:1 0 100%;order:-1;padding:2px 8px 10px 46px}
.file-card{display:flex;align-items:center;gap:8px;max-width:min(240px,100%);height:48px;padding:2px 8px 2px 2px;border:1px solid #2a2a2a;border-radius:14px;background:#1a1a1a;color:var(--ink)}
.file-card{display:flex;align-items:center;gap:8px;max-width:min(240px,100%);height:48px;padding:2px 8px 2px 2px;border:1px solid var(--border);border-radius:14px;background:var(--surface);color:var(--ink)}
.file-card-thumb,.file-card-badge{flex:0 0 44px;width:44px;height:44px;border-radius:11px;object-fit:cover}
.file-card-badge{display:grid;place-items:center;background:#2a2a2a;color:#c8c8c8;font-size:10px;font-weight:700;letter-spacing:.04em}
.file-card-badge{display:grid;place-items:center;background:var(--hairline);color:var(--muted);font-size:10px;font-weight:700;letter-spacing:.04em}
.file-card-meta{display:grid;min-width:0;flex:1}
.file-card-meta strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600}
.file-card-meta small{color:var(--muted);font-size:11px}
@ -49,7 +49,7 @@
.message.with-files .msg-attachments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:min(420px,82%)}
.message.with-files .message-body,.message.with-files .message-stack{max-width:82%}
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
.stop-send{background:#f2f2f2;color:#151515}
.stop-send{background:var(--cream);color:var(--on-send)}
.stop-send svg{width:14px;height:14px;fill:currentColor}
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
.message{position:relative;padding-right:28px}

View File

@ -3,20 +3,20 @@
.state-dot.running{background:var(--success)}
.state-dot.booting{background:#f5a03c;animation:pulse 1s infinite}
.state-dot.error{background:var(--danger)}
.desktop-frame{display:block;width:100%;height:100%;border:0;background:#101012}
.desktop-frame{display:block;width:100%;height:100%;border:0;background:var(--inset)}
.empty-computer{height:100%;display:grid;place-content:center;justify-items:center;gap:7px;color:var(--muted);text-align:center}
.empty-computer svg{width:28px}
.empty-computer span{font-size:12px}
.computer-caption{display:flex;align-items:center;justify-content:space-between;padding:14px 0;color:var(--muted)}
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid #171719;padding:16px 0}
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--hairline);padding:16px 0}
.computer-overlay{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:rgba(4,4,5,.98)}
.computer-overlay>header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid var(--line)}
.computer-overlay>header>div{display:flex;align-items:center;gap:10px}
.computer-overlay .avatar{width:30px;height:30px}
.control-badge{padding:5px 10px;border-radius:999px;background:rgba(48,162,75,.14);color:var(--success);font-size:13px}
.overlay-screen{flex:1;min-height:0;padding:20px;display:flex;justify-content:center}
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:#0d0d0e}
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:#2a1717;color:#fca5a5;padding:9px 15px;border-radius:9px}
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px}
.computer-toggle{background:var(--surface)}
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
.computer-part.hidden-part{display:none}
@ -27,11 +27,11 @@
.computer-part .preview{position:relative;flex:0 1 auto;width:100%;height:auto;min-height:0;max-height:min(52vh,380px);aspect-ratio:16 / 10}
.computer-part .preview .desktop-frame{display:block;aspect-ratio:16 / 10}
.overlay-screen .overlay-desktop{position:relative;width:min(100%,1440px);height:100%}
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:#0d0d0e}
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
.computer-hud{position:absolute;top:10px;left:10px;z-index:3;display:grid;justify-items:center;gap:6px;padding:10px 12px 8px;border-radius:16px;background:rgba(10,10,12,.72);backdrop-filter:blur(8px);pointer-events:none;box-shadow:0 10px 30px rgba(0,0,0,.35)}
.computer-hud .avatar.blobatar.thinking{animation:computer-hud-bob 1.6s ease-in-out infinite,avatar-rainbow-glow 1.8s linear infinite}
.computer-hud-label{font-size:12px;letter-spacing:.02em;line-height:1.3;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.empty-computer.is-waiting{min-height:100%;background:#101012}
.empty-computer.is-waiting{min-height:100%;background:var(--inset)}
.computer-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;flex-wrap:wrap}
.computer-actions .restart-computer{font-size:12px;padding-inline:10px;color:var(--muted)}
.computer-actions .restart-computer svg{width:14px;height:14px}
@ -61,10 +61,10 @@
.computer-power{position:relative;flex-shrink:0;margin-left:auto}
.computer-power-trigger{padding:8px;display:inline-flex;align-items:center;justify-content:center}
.computer-power-trigger svg{width:18px;height:18px}
.computer-power-menu{position:absolute;right:0;bottom:calc(100% + 8px);z-index:40;width:180px;padding:6px;border:1px solid var(--line,#343438);border-radius:12px;background:var(--surface,#242426);box-shadow:0 8px 28px #0005}
.computer-power-menu{position:absolute;right:0;bottom:calc(100% + 8px);z-index:40;width:180px;padding:6px;border:1px solid var(--line);border-radius:12px;background:var(--surface);box-shadow:0 8px 28px #0005}
.computer-power-menu button{display:flex;align-items:center;gap:9px;width:100%;padding:10px;border:0;border-radius:7px;background:transparent;color:var(--ink);text-align:left;cursor:pointer}
.computer-power-menu button:hover{background:var(--surface-hover,#ffffff0c)}
.computer-power-menu button:hover{background:var(--surface-hover)}
.computer-power-menu svg{width:16px;height:16px}
.computer-power-menu .computer-power-stop{color:#e98282}
.computer-power-menu small{display:block;padding:8px 10px 6px;border-top:1px solid var(--line,#343438);color:var(--muted);font-size:11px;line-height:1.6}
.computer-power-menu .computer-power-stop{color:var(--danger-soft)}
.computer-power-menu small{display:block;padding:8px 10px 6px;border-top:1px solid var(--line);color:var(--muted);font-size:11px;line-height:1.6}
.computer-overlay .computer-power-menu{top:calc(100% + 8px);bottom:auto}

View File

@ -1,18 +1,94 @@
import { useSyncExternalStore, type ReactNode } from "react";
import { en } from "./locales/en";
import { zhTW } from "./locales/zh-TW";
export const supportedLocales = ["zh-TW"] as const;
export const supportedLocales = ["zh-TW", "en"] as const;
export type Locale = (typeof supportedLocales)[number];
const messages = { "zh-TW": zhTW } as const;
const messages = { "zh-TW": zhTW, en } as const;
const LOCALE_STORE = "lazyboy.locale";
const listeners = new Set<() => void>();
export const locale: Locale = "zh-TW";
export type MessageKey = keyof typeof zhTW;
export type MessageParams = Record<string, string | number>;
function isLocale(value: string): value is Locale {
return (supportedLocales as readonly string[]).includes(value);
}
function readStoredLocale(): Locale {
try {
const raw = localStorage.getItem(LOCALE_STORE);
if (raw && isLocale(raw)) return raw;
} catch {
/* private mode */
}
return "zh-TW";
}
let current: Locale = "zh-TW";
try {
current = readStoredLocale();
} catch {
current = "zh-TW";
}
export function getLocale(): Locale {
return current;
}
export function htmlLang(locale: Locale = current): string {
return locale === "en" ? "en" : "zh-Hant";
}
export function dateLocale(locale: Locale = current): string {
return locale === "en" ? "en" : "zh-TW";
}
export function listJoin(items: string[]): string {
return items.join(current === "en" ? ", " : "、");
}
function applyDocumentLang(locale: Locale) {
if (typeof document === "undefined") return;
document.documentElement.lang = htmlLang(locale);
}
applyDocumentLang(current);
export function setLocale(next: Locale) {
if (next === current) return;
current = next;
try {
localStorage.setItem(LOCALE_STORE, next);
} catch {
/* private mode */
}
applyDocumentLang(next);
listeners.forEach((fn) => fn());
}
export function subscribeLocale(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function t(key: MessageKey, params?: MessageParams): string {
const message: string = messages[locale][key];
const table = messages[current];
const message: string = table[key] ?? messages["zh-TW"][key];
if (!params) return message;
return message.replace(/\{(\w+)\}/g, (match, name: string) =>
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
);
}
export function useLocale(): Locale {
return useSyncExternalStore(subscribeLocale, getLocale, getLocale);
}
export function LocaleRoot({ children }: { children: ReactNode }) {
useLocale();
return children;
}

457
apps/web/src/locales/en.ts Normal file
View File

@ -0,0 +1,457 @@
import type { zhTW } from "./zh-TW";
/** English UI copy. Keys must stay in lockstep with zh-TW. */
export const en: { [K in keyof typeof zhTW]: string } = {
search: "Search",
sharedComputer: "Shared computer",
privateComputer: "Private computer",
stopped: "Stopped",
booting: "Starting",
running: "Running",
suspended: "Sleeping",
error: "Error",
openComputer: "Start computer",
stopTask: "Stop task",
takeControl: "Take control",
takeOverNow: "Take over",
releaseControl: "Release control",
done: "Done",
skip: "Skip",
localWorkspace: "Local workspace",
pinned: "Pinned",
agentGroup: "Agent",
operationFailed: "That didnt work",
loadFailed: "Couldnt load",
settingsFailed: "Couldnt save settings",
loginFailed: "Couldnt sign in",
rememberFailed: "Couldnt remember that message",
clipboardWriteBlocked: "The browser blocked clipboard access.",
newConversation: "New chat",
workTools: "Work tools",
computer: "Computer",
memory: "Memory",
plugins: "Plugins",
pluginShort: "Plugin",
botSettings: "Bot settings",
add: "Add",
addBot: "New bot",
addGroup: "New group",
groups: "Groups",
unreadMessages: "{count} unread",
members: "{count} members",
hideHiddenItems: "Hide hidden items",
showHiddenItems: "Show hidden items",
mcpConnected: "{count} MCP connected",
connectMcpServer: "Connect an MCP server",
openOnPhone: "Open on phone",
settings: "Settings",
about: "About",
helpCenter: "Help",
sendFeedback: "Send feedback",
logout: "Sign out",
workspaceMenu: "Workspace menu",
chooseBot: "Choose a bot",
startRoomDiscussion: "Start a discussion with {name}",
startBotWork: "Start working with {name}",
roomWillReply: "{names} will all reply.",
botWelcome: "Send a message and it will get it done on its own computer.",
remembered: "Remembered",
remember: "Remember",
working: "{name} is working…",
copyCode: "Copy code",
copiedCode: "Copied",
copyMessage: "Copy",
copiedMessage: "Copied",
anotherConversationQueued: "Another chat is running, so this one is queued.",
workingStep: "{name} is working… {step}",
queuedMessages: "{count} more messages queued",
pausedUserControl: "Youre controlling the screen, so {name} is paused. Press “Release control” when youre done and it will continue from here (queued messages too).",
pausedNeedsUser: "{name} needs you on the screen (sign-in or verification). Press “Take over”, then “Release control” when youre done.",
releaseAndContinue: "Release and continue",
attachFile: "Attach file",
attachFileHint: "Images go to the model on this message. Other files land in the computer inbox and are deleted after two hours.",
attachTooMany: "Up to 4 attachments at a time",
attachTooLarge: "Each file must be 10 MB or smaller",
attachType: "That file type cant be attached",
attachRemove: "Remove {name}",
attachedFile: "Attached {name}",
attachDrop: "Drop to attach",
teachTask: "Teach a task",
teachTaskHint: "You demonstrate once; it turns that into a skill",
teachNeedsBot: "Choose a bot first",
taughtSkills: "Taught skills",
goalCommandHint: "Keep planning and executing until its verified or it needs you",
searchSkills: "Search skills",
noMatchingSkills: "No matching skills",
teachDialogLead: "Youll drive the screen while {name} watches: which controls you click, what you type, which pages you visit. It becomes a skill about intent and flow, not replayed coordinates.",
teachGoalLabel: "What are you demonstrating? (one sentence)",
teachGoalPlaceholder: "For example: open the STAR training system, finish the assigned videos, and complete the quiz",
teachTip1: "Work at a normal pace. Extra clicks are ignored.",
teachTip2: "Values that change (search terms, names, dates) can become parameters — use a real example while you demonstrate.",
teachTip3: "Password fields are not recorded. Sign in before you start if you need to.",
startDemo: "Start demo",
finishDemo: "Finish demo",
teachingBadge: "Recording",
teachingComposerHint: "Recording: finish the demo before sending a message",
teachingLive: "Learning: {goal}",
teachingHint: "The screen is yours — work on the right (or enlarge it). Press “Finish demo” when youre done.",
teachingCaptured: "{count} actions captured",
distilling: "Turning the “{goal}” demo into a skill…",
skillDraftTitle: "New skill draft",
skillDraftFrom: "From {count} actions",
skillName: "Skill name",
skillNamePlaceholder: "For example: Complete STAR training course",
skillInputs: "Inputs",
skillSteps: "Steps (intent, editable)",
moreSteps: "…and {count} more",
editSteps: "Edit steps",
skillCheck: "How to know its done",
skillDistillFailed: "The model couldnt tidy this up. This list is from the raw actions — edit before saving.",
discard: "Discard",
testRun: "Try it",
saveSkill: "Save skill",
messageTo: "Message {name}",
chooseConversationFirst: "Choose a chat first",
stopConversation: "Stop",
editSkill: "Edit skill",
editSkillFull: "Full edit",
runSkillNamed: "Run “{name}”",
skillEditLead: "Write this for the bot: intent and on-screen wording, not coordinates. One item per line.",
skillIntent: "Intent (what this skill should achieve)",
skillWhenToUse: "When to use it",
skillInputsLabel: "Inputs (one per line: name: description | e.g. example)",
skillInputsPlaceholder: "courseName: course title | e.g. Workplace Bullying Prevention (2026)",
skillPreconditions: "Preconditions",
skillStepsLabel: "Steps (one per line; use → for what you should see)",
skillStepsPlaceholder: "Click “Next” → page number increases; if Next is locked, wait until it enables",
skillHowToCheck: "How to know its done",
skillWhatToReturn: "What to report when finished",
skillCautions: "Cautions (one per line)",
deleteSkill: "Delete skill",
deleteSkillConfirm: "Delete skill “{name}”? The bot wont recognize it after that.",
skillSaved: "Saved",
exportSkill: "Export",
exportSkillHint: "Download as JSON to import on another bot",
importSkill: "Import skill",
importSkillHint: "Add a skill from a previously exported JSON file",
skillImportInvalid: "That isnt a valid skill file. Choose a JSON export from LazyBoy.",
collapseSidebar: "Collapse sidebar",
botComputer: "{name}s computer",
dedicatedScreen: "This screen",
enlarge: "Enlarge",
userControlling: "Youre in control",
aiReadOnly: "AI is working (view only)",
readOnly: "View only",
pasteClipboard: "Paste clipboard",
copyDesktopClipboard: "Copy desktop clipboard",
moreActions: "More",
unpin: "Unpin",
pin: "Pin",
markUnread: "Mark unread",
enterGroupName: "Enter a group name",
changeGroup: "Change group",
createOrMoveGroup: "Create / move to group",
removeFromGroup: "Remove from group",
unhide: "Unhide",
hide: "Hide",
delete: "Delete",
chooseConversation: "Choose a chat",
deleteConversation: "Delete chat",
clearConversation: "Clear this chat",
clearConversationTitle: "Clear this chat?",
clearConversationDescription: "Messages will be deleted. This agents long-term memory stays.",
cancel: "Cancel",
clearMessages: "Clear messages",
deleteConversationTitle: "Delete this chat?",
deleteConversationDescription: "It will leave the list. This agents long-term memory stays.",
avatarAppearance: "Avatar",
avatarAppearanceHint: "Color, shape, expression, and background. Eyes follow the cursor.",
color: "Color",
chooseColor: "Choose {value}",
customColor: "Custom color",
shape: "Shape",
expression: "Expression",
background: "Background",
bgNone: "None",
bgCircle: "Circle",
bgSquircle: "Squircle",
bgSquare: "Square",
exprIdle: "Idle",
exprHappy: "Happy",
exprSad: "Sad",
exprMad: "Mad",
exprSurprised: "Surprised",
exprWink: "Wink",
exprSleepy: "Sleepy",
exprSmug: "Smug",
exprUnsure: "Unsure",
exprScared: "Scared",
exprLove: "Love",
exprShy: "Shy",
exprSick: "Sick",
exprThinking: "Thinking",
name: "Name",
tags: "Tags",
tagsPlaceholder: "research, design, support (comma-separated)",
tagsLimit: "Up to 6 tags",
shortTitle: "Short title",
shortTitlePlaceholder: "For example: product research assistant",
description: "Description",
descriptionPlaceholder: "What this bot is for",
saving: "Saving…",
saveSettings: "Save settings",
deleteBot: "Delete bot",
memoryHelp: "Clearing a chat doesnt delete these. Store clear preferences or facts — never passwords.",
enableLongTermMemory: "Long-term memory",
addMemory: "Add memory",
memoryPlaceholder: "Clear preferences or facts only. Passwords and tokens are rejected.",
memorySearch: "Search",
filterMemory: "Filter memory",
noMemory: "No long-term memory yet. Ask the agent to remember something from chat, or add it here.",
noMatchingMemory: "No matching memories.",
save: "Save",
edit: "Edit",
clearAllMemoryConfirm: "Clear all memory?",
clearMemoryConfirm: "Clear",
clearAll: "Clear all",
mcpHelp: "Press “Choose MCP” to connect from the catalog. If a key is required, fill it in first. Local stdio servers start inside the API container.",
mcpNamePlaceholder: "For example: github",
mcpConnectFailed: "Couldnt connect. Check the key and try again.",
command: "Command",
commandPlaceholder: "npx, uvx, or a full path",
arguments: "Arguments",
environmentVariables: "Environment KEY=value",
headers: "Headers KEY=value",
connecting: "Connecting…",
connectMcp: "Connect MCP",
chooseMcp: "Choose MCP",
mcpPickerTitle: "Choose MCP",
mcpPickerLead: "Pick a featured or registry server. Every agent can use its tools.",
searchMcp: "Search MCP",
mcpRemote: "Remote",
mcpLocal: "Local",
mcpAdded: "Connected",
mcpNeedsKey: "Needs a key",
mcpNoResults: "No matching MCP. Try another search, or connect a custom server.",
mcpCustom: "Custom",
mcpBackToList: "Back to list",
mcpConnectNamed: "Connect {name}",
mcpKeyHint: "This MCP needs credentials.",
noMcp: "No MCP yet. Press “Choose MCP” above to connect one from the catalog.",
toolsCount: "{count} tools",
disabled: "Off",
disconnected: "Disconnected",
noTools: "No tools",
reconnect: "Reconnect",
disable: "Disable",
enable: "Enable",
preparingDesktop: "Preparing this agents desktop…",
computerPreviewHint: "Start the computer and the screen will show up here.",
bootingProgress: "Starting…",
restartComputer: "Restart",
shutDownComputer: "Shut down",
computerPower: "Computer menu",
sharedPowerHint: "This is a shared computer. The action affects every agent using it.",
stopAndTakeOver: "Stop and take over",
hudBooting: "Computer starting…",
hudWaking: "Waking…",
hudConnecting: "Connecting…",
hudHandoff: "Handing off…",
pasteToRemoteComputer: "Paste to remote computer",
pasteRemoteHelp: "Paste text here, then send it into VNC. This works over plain HTTP on the LAN.",
pasteTextPlaceholder: "Paste text here…",
pasteIntoVnc: "Paste into VNC",
botNamePlaceholder: "For example: research assistant",
sharedComputerHint: "Shares the environment with other bots",
privateComputerHint: "A fresh dedicated Docker desktop",
create: "Create",
groupDescription: "A group opens one chat where every selected agent speaks.",
groupName: "Group name",
groupNamePlaceholder: "For example: product research",
chooseBots: "Choose bots (at least two)",
creating: "Creating…",
createGroup: "Create group",
deleteGroup: "Delete group",
deleteNamed: "Delete {name}?",
deleteGroupDescription: "The group chat will be deleted. The bots and their own chats and memory stay.",
deleteDedicatedBotDescription: "The chat, private computer, and files on it will be permanently deleted.",
deleteSharedBotDescription: "The chat will be deleted. The shared computer and its files stay.",
phoneAccessDescription: "On the same LAN, open this URL in a phone browser and sign in with the same access token.",
localAddressWarning: "This is a loopback address — a phone cant open it. Use this computers LAN IP, for example http://192.168.x.x:3101.",
close: "Close",
copied: "Copied",
copyUrl: "Copy URL",
workspaceName: "Workspace name",
showHiddenBots: "Show hidden bots",
collapseRightSidebar: "Collapse right sidebar",
workspaceSettingsHint: "Avatar, memory, and MCP live in the right-hand tabs. This only changes how this workspace is shown.",
modelSettings: "Models",
modelSettingsHint: "This provider and model apply to bots that dont have their own.",
modelProvider: "Model provider",
providerXai: "xAI",
providerOpencodeGo: "OpenCode Go",
providerOpenaiCompatible: "OpenAI-compatible",
providerXaiHint: "Grok. Get a key from console.x.ai.",
providerOpencodeGoHint: "OpenCode Go subscription. Get a key from opencode.ai/auth.",
providerOpenaiCompatibleHint: "Self-hosted vLLM, Ollama, LiteLLM, or another OpenAI-compatible endpoint.",
apiKey: "API key",
apiKeyPlaceholder: "sk-…",
apiKeyStored: "A key is saved. Leave blank to keep it.",
usingEnvKey: "Using environment variable {name}.",
clearApiKey: "Clear saved key",
modelId: "Model",
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
reloadModels: "Reload models",
baseUrl: "Base URL",
baseUrlPlaceholder: "http://127.0.0.1:11434/v1",
baseUrlHint: "If the API runs in Docker, use host.docker.internal instead of localhost for services on this machine.",
workspaceModelHint: "Pick a provider, then the key and model. OpenAI-compatible needs your own endpoint.",
aboutDescription: "A local multi-agent workspace. Each bot has its own Linux computer. You can put them in a group chat and connect MCP tools.",
apiStatus: "API status: {status}",
statusNormal: "OK",
statusUnavailable: "Unreachable",
statusChecking: "Checking…",
helpBotsTitle: "Bots",
helpBots: "Use + at the top left to add a bot. Click the list to chat. Right-click to pin, hide, or delete.",
helpGroupsTitle: "Groups",
helpGroups: "+ → New group, pick at least two. One message, every agent in the group replies.",
helpComputerTitle: "Computer",
helpComputer: "The Computer tab on the right is this agents desktop. Start it, take over mouse and keyboard, or let it drive.",
helpMemoryTitle: "Memory",
helpMemory: "Clearing a chat doesnt wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.",
helpMcpTitle: "MCP plugins",
helpMcp: "Plugins in the lower left connect MCP servers. Their tools show up on screen and the agent can use them in chat.",
helpSkillsTitle: "Skills",
helpSkills: "+ → Teach a task, demonstrate once, and it becomes a skill. Export JSON after, or import someone elses file onto another bot.",
helpAttachTitle: "Attachments",
helpAttach: "+ → Attach a file. Images go to the model on that message and arent stored in chat history. If the computer needs the original, it sits in inbox/ for two hours, then is deleted.",
helpShortcutsTitle: "Shortcuts",
helpShortcuts: "Enter sends, Shift+Enter makes a new line. While its replying, the send button becomes Stop. On a call, Space interrupts and Esc hangs up.",
helpVoiceTitle: "Voice calls",
helpVoice: "The phone in a 1:1 header calls the bot. Speak a task and it uses its computer. It answers immediately whether it can, and you can interrupt to change course.",
call: "Call",
hangUp: "Hang up",
interrupt: "Interrupt",
listening: "Listening…",
speaking: "Speaking…",
callConnecting: "Connecting…",
workingOnComputer: "Working on the computer…",
callPrompt: "Just say what you need. A short pause sends it.",
callShortcuts: "Space interrupts · Esc hangs up",
callFailed: "The call dropped.",
micDenied: "Microphone permission is required for calls.",
micFailed: "Couldnt open the microphone.",
callAudioFailed: "Call audio stopped working. Hang up and call again.",
takeOverToContinue: "You need to take over the screen.",
setUpVoiceToCall: "Set up voice before calling",
voiceEnabled: "Enable voice calls",
voiceEnabledHint: "Off by default. Turn on and save to use the call button.",
voiceDisabledHint: "Voice calls are off. Turn them on in Settings → Voice.",
voiceSettings: "Voice",
voiceSettingsHint: "Voice is separate from the text model. If you already have an xAI chat key, you usually dont need another. Billed per minute.",
voiceProvider: "Voice provider",
voiceModel: "Voice model",
voiceName: "Voice",
providerOpenai: "OpenAI",
providerXaiVoiceHint: "Grok Voice. Live calls with barge-in; computer work is handed to the text model.",
providerOpenaiVoiceHint: "OpenAI Realtime. Same call UI; you can switch later.",
voiceReusesTextKey: "Reusing the xAI key saved for the text model.",
feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.",
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
copyContent: "Copy",
loginTitle: "Sign in to LazyBoy",
loginDescription: "Enter the shared access token configured on the server.",
accessToken: "Access token",
verifying: "Checking…",
login: "Sign in",
agentComputer: "Agent computer",
url: "URL",
stdio: "stdio",
http: "HTTP",
sse: "SSE",
argumentsPlaceholder: "-y @modelcontextprotocol/server-github",
environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
urlPlaceholder: "https://mcp.example.com/mcp",
headersPlaceholder: "Authorization=Bearer …",
back: "Back",
accounts: "Accounts",
accountsHelp: "Stored in this bots vault. Passwords are entered only here — never in chat, never shown to the model. Schedules fill the form at a login wall.",
accountSite: "Site",
accountSitePlaceholder: "For example: Gmail",
accountHost: "URL or domain",
accountHostPlaceholder: "mail.google.com",
accountUsername: "Username",
accountPassword: "Password",
accountPasswordKeep: "Leave blank to keep the current password",
accountNotes: "Notes",
accountNotesPlaceholder: "Optional, for example: work inbox",
addAccount: "Add account",
noAccounts: "No saved accounts yet. Add one here if a schedule should sign in by itself.",
loginNeedsYou: "Needs you to sign in on the keyboard",
loginOpenScreen: "Open its screen",
loginWhy: "After sign-in: {why}",
scheduleChip: "Scheduled",
scheduleRunChip: "Schedule run",
schedules: "Schedules",
schedule: "Schedule",
schedCreate: "New schedule",
schedEmpty: "No schedules yet. Say “every day at 9…” in chat, or press +.",
schedPaused: "Paused",
schedRunNow: "Run now",
schedRunning: "Running…",
schedActive: "On",
schedNamePlaceholder: "For example: morning digest",
schedInstruction: "What to do each time",
schedInstructionPlaceholder: "For example: open mail and write an unread summary back to chat.",
schedWhen: "When",
schedEveryHour: "Every hour",
schedEveryDay: "Every day",
schedWeekdays: "Weekdays",
schedEveryWeek: "Every Monday",
schedEveryMonth: "Monthly",
schedInterval: "Interval",
schedAdvanced: "Advanced cron",
schedMinutes: "minutes",
schedHours: "hours",
schedDays: "days",
computerLoginHint: "Sign in inside this bots own browser. The session stays on its computer — dont type passwords in chat.",
language: "Language",
languageZh: "中文",
languageEn: "English",
languageHint: "Interface language. Chat history and agent replies are not translated.",
helpLanguageTitle: "Language",
helpLanguage: "Switch Chinese and English from the workspace menu or Settings.",
clipboardSynced: "Clipboard synced",
clipboardSyncBlocked: "The browser blocked sync. Use the copy button.",
clipboardPasted: "Pasted",
clipboardPasteFailed: "Paste failed. Take over the computer and try again.",
clipboardCopiedSelection: "Copied selection",
clipboardCopyFailed: "Copy didnt sync. Use the copy button, or Ctrl+Shift+C on the desktop.",
slashMode: "Mode",
slashFileSkill: "Skill",
slashList: "Commands and skills",
schedIntervalHint: "Elapsed time, not the clock. A day is 24 hours and doesnt reset at month-end or DST.",
schedInvalidInterval: "Interval must be a whole number from 1 to 365",
schedHumanDaily: "Daily at {time}",
schedHumanWeekdays: "Weekdays at {time}",
schedHumanWeekly: "Mondays at {time}",
schedHumanMonthly: "Monthly at {time}",
schedHumanInterval: "Every {n} {unit}",
stepThinking: "Thinking",
stepWatchingScreen: "Looking at the screen",
stepUsingSkill: "Using skill {name}",
stepFillingLogin: "Filling a saved login",
stepListingAccounts: "Listing saved accounts",
stepScheduling: "Scheduling {name}",
stepListingSchedules: "Listing schedules",
stepCancelingSchedule: "Canceling a schedule",
stepRecheckingScreen: "Checking the screen again",
schedEveryMinute: "Every minute",
schedHumanEveryMinutes: "Every {n} minutes",
schedHumanEveryHours: "Every {n} hours",
schedHumanMonthlyDay: "Monthly on the 1st at {time}",
schedHumanElapsed: "Every {n} minutes (elapsed)",
schedCalendar: "Calendar schedule: {expr}",
teachInProgress: "A demo is in progress. Finish or cancel it before sending a message.",
aiTimeout: "The model timed out (120 seconds).",
};

View File

@ -98,6 +98,7 @@ export const zhTW = {
call: "通話", hangUp: "掛斷", interrupt: "插話", listening: "正在聽你說…", speaking: "正在說話…",
callConnecting: "連線中…", workingOnComputer: "正在電腦上做…", callPrompt: "直接說要做什麼。沉默一下就會送出。",
callShortcuts: "空白鍵插話 · Esc 掛斷", callFailed: "通話中斷了。", micDenied: "需要麥克風權限才能通話。", micFailed: "麥克風打不開。",
callAudioFailed: "通話聲音出問題了,請掛斷後重打。",
takeOverToContinue: "需要你接手畫面。", setUpVoiceToCall: "先設定語音才能通話",
voiceEnabled: "啟用語音通話", voiceEnabledHint: "預設關閉。開啟並儲存後,才能使用通話按鈕。", voiceDisabledHint: "語音通話尚未啟用,請到「設定 → 語音」開啟。",
voiceSettings: "語音", voiceSettingsHint: "語音跟文字模型分開。xAI 若已有聊天金鑰,通常不用再填一次。按分鐘計費。",
@ -125,4 +126,27 @@ export const zhTW = {
schedEveryWeek: "每週一", schedEveryMonth: "每月", schedInterval: "間隔", schedAdvanced: "進階 cron",
schedMinutes: "分鐘", schedHours: "小時", schedDays: "天",
computerLoginHint: "在這台機器人自己的瀏覽器登入。Session 留在它的電腦,不要把密碼打在聊天裡。",
language: "語言", languageZh: "中文", languageEn: "English",
languageHint: "介面語言。對話內容與 Agent 回覆不會自動翻譯。",
helpLanguageTitle: "語言", helpLanguage: "工作區選單或設定裡可切換中文與英文。",
clipboardSynced: "剪貼簿已同步", clipboardSyncBlocked: "瀏覽器未允許同步,請按複製按鈕",
clipboardPasted: "已貼上文字", clipboardPasteFailed: "貼上失敗,請確認已接管電腦後重試",
clipboardCopiedSelection: "已複製選取文字", clipboardCopyFailed: "複製未同步;請按複製按鈕,或在遠端使用 Ctrl+Shift+C",
slashMode: "執行模式", slashFileSkill: "檔案技能", slashList: "指令與技能",
schedIntervalHint: "固定經過時間;一天為 24 小時,不因月底或日光節約時間重置。",
schedInvalidInterval: "間隔必須為 1365 的整數",
schedHumanDaily: "每天 {time}", schedHumanWeekdays: "工作日 {time}",
schedHumanWeekly: "每週一 {time}", schedHumanMonthly: "每月 {time}",
schedHumanInterval: "每 {n} {unit}",
stepThinking: "思考中", stepWatchingScreen: "看畫面", stepUsingSkill: "讀取技能 {name}",
stepFillingLogin: "填入已存帳號", stepListingAccounts: "列出已存帳號",
stepScheduling: "排程 {name}", stepListingSchedules: "列出排程", stepCancelingSchedule: "取消排程",
stepRecheckingScreen: "重新確認畫面",
schedEveryMinute: "每分鐘",
schedHumanEveryMinutes: "每 {n} 分鐘", schedHumanEveryHours: "每 {n} 小時",
schedHumanMonthlyDay: "每月 1 日 {time}",
schedHumanElapsed: "每隔 {n} 分鐘(固定間隔)",
schedCalendar: "日曆排程:{expr}",
teachInProgress: "示範進行中:先按「完成示範」或「取消」,再送訊息。",
aiTimeout: "AI 回應逾時120 秒)",
} as const;

View File

@ -11,4 +11,5 @@ import "./schedule.css";
import "./call.css";
import "./responsive.css";
import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
import { LocaleRoot } from "./i18n";
ReactDOM.createRoot(document.getElementById("root")!).render(<LocaleRoot><App /></LocaleRoot>);

View File

@ -22,13 +22,13 @@
}
.md-body a:hover{color:#7ee0c8}
.md-body code{
border:1px solid var(--border);border-radius:.35em;background:#101012;
border:1px solid var(--border);border-radius:.35em;background:var(--inset);
padding:.08em .32em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:.88em;direction:ltr;unicode-bidi:isolate
}
.md-body pre{
max-width:100%;overflow-x:auto;margin:0;border:1px solid var(--border);
border-radius:12px;background:#0c0c0d;padding:.75em .9em;direction:ltr;unicode-bidi:isolate
border-radius:12px;background:var(--input);padding:.75em .9em;direction:ltr;unicode-bidi:isolate
}
.md-body pre code{border:0;background:transparent;padding:0;font-size:.84em;white-space:pre}
.md-pre-wrap{position:relative;margin:.65em 0}
@ -51,6 +51,6 @@
.md-body img{display:block;max-width:100%;height:auto;margin:.65em 0;border-radius:12px}
.md-body input[type="checkbox"]{margin-right:.4em;accent-color:var(--accent)}
.message-body.md{white-space:normal}
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:#1a1a1a}
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:var(--on-cream)}
.user .md-body a{color:#0f766e}
.user .md-copy{background:#ececea;color:#555}

View File

@ -6,13 +6,13 @@
.memory-list{display:grid;gap:8px;margin:14px 0}
.memory-row{display:grid;gap:5px;padding:10px;border:1px solid var(--border,#ddd);border-radius:10px}
.memory-row{display:grid;gap:5px;padding:10px;border:1px solid var(--border);border-radius:10px}
.memory-row small{opacity:.65}
.memory-row>div{display:flex;gap:8px}
.memory-row textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit}
.memory-row textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:11px;font:inherit}
.app-shell{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,28vw,420px)}
@ -52,7 +52,7 @@
.primary{display:inline-flex;align-items:center;justify-content:center}
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:#0c0c0d;color:var(--text);padding:12px;font:inherit;line-height:1.5;outline:none}
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:var(--input);color:var(--ink);padding:12px;font:inherit;line-height:1.5;outline:none}
.clipboard-text:focus{border-color:var(--accent)}
@ -66,9 +66,9 @@
.bot-copy strong,.bot-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--text);background:rgba(255,255,255,.055)}
.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--ink);background:rgba(255,255,255,.055)}
.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--sidebar,#0b0b0c);border-radius:50%;background:#35d07f;box-shadow:0 0 8px rgba(53,208,127,.75)}
.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:var(--unread);box-shadow:0 0 8px color-mix(in srgb,var(--unread) 65%,transparent)}
.bot-group{display:grid;gap:3px}
@ -83,9 +83,9 @@
.hidden-toggle svg{width:14px}
.context-menu{position:fixed;z-index:100;display:grid;width:210px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.context-menu{position:fixed;z-index:100;display:grid;width:210px;padding:6px;border:1px solid var(--border);border-radius:13px;background:var(--menu);box-shadow:0 18px 60px rgba(0,0,0,.55)}
.context-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;text-align:left;cursor:pointer}
.context-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.context-menu button:hover{background:rgba(255,255,255,.07)}
@ -93,7 +93,7 @@
.context-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}
.context-menu .danger-item{color:#ff7777}
.context-menu .danger-item{color:var(--danger-soft)}
.context-menu .context-close{display:none}
@ -110,7 +110,7 @@
.color-grid button,.custom-color{position:relative;box-sizing:border-box;width:34px;height:34px;border:2px solid transparent;border-radius:50%;cursor:pointer}
.color-grid button.selected{outline:2px solid var(--text);outline-offset:2px}
.color-grid button.selected{outline:2px solid var(--ink);outline-offset:2px}
.custom-color{display:grid;place-items:center;overflow:hidden;border:1px dashed var(--muted)}
@ -122,7 +122,7 @@
.shape-grid button.selected{border-color:var(--accent);background:rgba(62,197,168,.08)}
.settings-dialog textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--text);padding:11px;font:inherit;outline:none}
.settings-dialog textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:11px;font:inherit;outline:none}
.settings-dialog textarea:focus{border-color:var(--accent)}
@ -178,7 +178,7 @@
.workspace-avatar{display:grid;width:32px;height:32px;flex:0 0 32px;place-items:center;border-radius:50%;background:var(--surface);color:var(--muted);font-size:11px;font-weight:650;letter-spacing:.02em;box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)}
.account:hover,.account.open{background:#171719}
.account:hover,.account.open{background:var(--hairline)}
.account .chevron{width:16px;height:16px;color:var(--muted);margin-left:auto;flex:0 0 16px;transition:transform .15s ease}
@ -187,7 +187,7 @@
.account.open .chevron{transform:rotate(180deg)}
.account-menu{position:absolute;z-index:50;left:0;right:0;bottom:calc(100% + 8px);display:grid;padding:6px;border:1px solid var(--border);border-radius:14px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.55)}
.account-menu{position:absolute;z-index:50;left:0;right:0;bottom:calc(100% + 8px);display:grid;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 18px 50px rgba(0,0,0,.55)}
.account-menu button{display:flex;align-items:center;gap:10px;height:40px;padding:0 12px;border:0;border-radius:9px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
@ -205,7 +205,7 @@
.account-menu hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--border)}
.share-url{display:block;padding:10px 12px;border-radius:10px;background:#0c0c0d;color:var(--ink);font-size:13px;word-break:break-all}
.share-url{display:block;padding:10px 12px;border-radius:10px;background:var(--input);color:var(--ink);font-size:13px;word-break:break-all}
.about-brand{display:grid;justify-items:center;gap:6px;padding:8px 0 4px}
@ -235,7 +235,7 @@
.plugin-row,.plugin-server{display:flex;align-items:center;gap:10px;width:100%;padding:9px 10px;border:0;border-radius:12px;background:transparent;color:inherit;text-align:left;cursor:pointer}
.plugin-row:hover,.plugin-server:hover,.plugin-row.selected{background:#171719}
.plugin-row:hover,.plugin-server:hover,.plugin-row.selected{background:var(--hairline)}
.plugin-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:rgba(255,255,255,.06);color:var(--muted);flex:0 0 28px}
@ -253,13 +253,13 @@
.mcp-dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:0 0 8px}
.mcp-dot.connected{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.6)}
.mcp-dot.connected{background:var(--online);box-shadow:0 0 8px color-mix(in srgb,var(--online) 60%,transparent)}
.mcp-dot.disconnected{background:#ef5555}
.mcp-dot.disconnected{background:var(--danger)}
.mcp-dot.disabled{background:#626267}
.mcp-dot.disabled{background:var(--faint)}
.mcp-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px}
@ -292,7 +292,7 @@
.mcp-card-head small{color:var(--muted)}
.mcp-error{padding:0 12px 10px;color:#ff8585;font-size:12px;line-height:1.4}
.mcp-error{padding:0 12px 10px;color:var(--danger-soft);font-size:12px;line-height:1.4}
.mcp-tools{display:grid;gap:6px;padding:0 12px 10px}
@ -321,7 +321,7 @@
.mcp-picker .dialog-title,.mcp-picker .dialog-lead,.mcp-picker .dialog-actions,.mcp-picker .pane-error{flex:0 0 auto}
.dialog label.mcp-picker-search,.mcp-picker-search{display:flex;align-items:center;gap:10px;height:44px;margin:0;padding:0 13px;border:1px solid var(--line);border-radius:14px;background:#121214;color:var(--muted);font-size:inherit;flex:0 0 44px}
.dialog label.mcp-picker-search,.mcp-picker-search{display:flex;align-items:center;gap:10px;height:44px;margin:0;padding:0 13px;border:1px solid var(--line);border-radius:14px;background:var(--elevated);color:var(--muted);font-size:inherit;flex:0 0 44px}
.mcp-picker-search>div{flex:0 0 auto;display:grid;place-items:center;line-height:0}
@ -379,9 +379,9 @@
.brand .create-menu-wrap .icon-button{margin-left:0}
.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.5)}
.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:var(--menu);box-shadow:0 18px 50px rgba(0,0,0,.5)}
.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);cursor:pointer}
.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);cursor:pointer}
.create-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}
@ -413,7 +413,7 @@
.session-current svg{flex:0 0 14px;width:14px;height:14px;color:var(--muted)}
.session-menu{position:absolute;z-index:40;top:42px;left:0;display:grid;width:min(280px,calc(100vw - 24px));padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.session-menu{position:absolute;z-index:40;top:42px;left:0;display:grid;width:min(280px,calc(100vw - 24px));padding:6px;border:1px solid var(--border);border-radius:13px;background:var(--menu);box-shadow:0 18px 60px rgba(0,0,0,.55)}
.session-menu>button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
@ -452,7 +452,7 @@
.session-item .icon-button{flex:0 0 32px;width:32px;height:32px;color:var(--muted)}
.session-menu .danger-item{color:#ff7777}
.session-menu .danger-item{color:var(--danger-soft)}
.queue-hint{position:absolute;bottom:118px;left:50%;z-index:7;transform:translateX(-50%);color:var(--muted);font-size:12px}
@ -461,7 +461,7 @@
.error-banner{z-index:8;bottom:118px}
.pause-banner{display:flex;align-items:center;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid #5a4a1f;border-radius:12px;background:#221c0e;color:#fcd68a;font-size:13px;line-height:1.5}
.pause-banner{display:flex;align-items:center;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid var(--warn-border);border-radius:12px;background:var(--warn-bg);color:var(--warn-fg);font-size:13px;line-height:1.5}
.pause-banner span{flex:1}
@ -518,7 +518,7 @@
.side-rail button:disabled{opacity:.35;cursor:not-allowed}
.side-card-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid #171719}
.side-card-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid var(--hairline)}
.side-card-title{min-width:0;flex:1;overflow:hidden;color:var(--muted);font-size:13px;text-overflow:ellipsis;white-space:nowrap}
@ -551,16 +551,16 @@
.pane-form label,.memory-pane label,.mcp-add label,.vault-pane label{display:grid;gap:8px;color:var(--muted);font-size:13px;min-width:0}
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input,.vault-pane input{width:100%;min-width:0;height:40px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input,.vault-pane input{width:100%;min-width:0;height:40px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:0 12px;outline:0}
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit;outline:none}
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:11px;font:inherit;outline:none}
.pane-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.pane-error{color:#ff8585;font-size:13px}
.pane-error{color:var(--danger-soft);font-size:13px}
.settings-pane fieldset{margin:0;padding:0;border:0}
@ -597,13 +597,19 @@
.login-dialog p{margin-top:-8px;color:var(--muted)}
.login-error{color:#ff8585;font-size:13px}
.login-error{color:var(--danger-soft);font-size:13px}
.login-lang{display:flex;justify-content:center;gap:6px;margin-bottom:4px}
.login-lang button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
.login-lang button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
@keyframes working-shimmer{to{background-position:-220% 0}}
.bot-row .unread-dot{top:-2px;right:-3px;background:#3b82f6;box-shadow:0 0 8px rgba(59,130,246,.65)}
.bot-row .unread-dot{top:-2px;right:-3px}
.bot-row:hover .avatar-wrap{transform:scale(1.06)}
@ -671,14 +677,14 @@
.dialog.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}
.dialog.settings-dialog select{width:100%;height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink)}
.dialog.settings-dialog select{width:100%;height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink)}
/* Teach-by-demonstration: composer menu, live banner, draft card */
.plus-menu-wrap{position:relative;align-self:center}
.plus-menu{position:absolute;z-index:40;bottom:50px;left:0;display:grid;width:268px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.plus-menu{position:absolute;z-index:40;bottom:50px;left:0;display:grid;width:268px;padding:6px;border:1px solid var(--border);border-radius:13px;background:var(--menu);box-shadow:0 18px 60px rgba(0,0,0,.55)}
.plus-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
@ -702,10 +708,10 @@
.plus-menu-skills{display:grid;min-height:0}
.plus-menu-search{box-sizing:border-box;width:calc(100% - 8px);height:32px;margin:4px 4px 6px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:#121214;color:var(--ink);font:inherit;font-size:13px;outline:0}
.plus-menu-search{box-sizing:border-box;width:calc(100% - 8px);height:32px;margin:4px 4px 6px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--elevated);color:var(--ink);font:inherit;font-size:13px;outline:0}
.plus-menu-search:focus{border-color:#4b4b50}
.plus-menu-search:focus{border-color:var(--focus)}
.plus-menu-skill-list{overflow-y:auto;max-height:min(228px,calc(100dvh - 280px));padding-bottom:2px}
@ -720,7 +726,7 @@
.plus-menu-empty{display:block;padding:10px 12px 12px;color:var(--muted);font-size:12px}
.record-dot{display:inline-block;flex:0 0 12px;width:12px;height:12px;border-radius:50%;background:#ef5555;box-shadow:inset 0 0 0 2px #18181b,0 0 0 1.5px #ef5555}
.record-dot{display:inline-block;flex:0 0 12px;width:12px;height:12px;border-radius:50%;background:var(--danger);box-shadow:inset 0 0 0 2px var(--menu),0 0 0 1.5px var(--danger)}
.record-dot.live{animation:pulse 1.2s infinite;margin-right:8px;vertical-align:-1px}
@ -729,10 +735,10 @@
.teach-dialog h2{display:flex;align-items:center;gap:10px}
.teach-goal{min-height:78px;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:10px 12px;outline:0;line-height:1.5}
.teach-goal{min-height:78px;resize:vertical;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:10px 12px;outline:0;line-height:1.5}
.teach-goal:focus{border-color:#4b4b50}
.teach-goal:focus{border-color:var(--focus)}
.teach-tips{margin:0;padding-left:18px;color:var(--muted);font-size:12.5px;line-height:1.6}
@ -768,10 +774,10 @@
.skill-draft label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
.skill-draft input,.skill-draft textarea{border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;line-height:1.5}
.skill-draft input,.skill-draft textarea{border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:9px 12px;outline:0;font:inherit;line-height:1.5}
.skill-draft input:focus,.skill-draft textarea:focus{border-color:#4b4b50}
.skill-draft input:focus,.skill-draft textarea:focus{border-color:var(--focus)}
.skill-draft textarea{resize:vertical}
@ -798,7 +804,7 @@
.skill-draft .link{width:fit-content;padding:0;border:0;background:none;color:var(--accent);font-size:12px;cursor:pointer}
.skill-error{color:#fca5a5;font-size:12.5px}
.skill-error{color:var(--error-fg);font-size:12.5px}
.skill-actions{display:flex;align-items:center;gap:8px}
@ -845,10 +851,10 @@
.dialog.skill-edit label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;font-size:13.5px;line-height:1.5;resize:vertical}
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:9px 12px;outline:0;font:inherit;font-size:13.5px;line-height:1.5;resize:vertical}
.dialog.skill-edit input:focus,.dialog.skill-edit textarea:focus{border-color:#4b4b50}
.dialog.skill-edit input:focus,.dialog.skill-edit textarea:focus{border-color:var(--focus)}
.dialog.skill-edit .dialog-actions{align-items:center;gap:8px;flex-wrap:wrap}
@ -876,7 +882,7 @@
.vault-empty{margin:0;color:var(--faint);font-size:13px;line-height:1.5}
.account-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0;padding:10px 6px 10px 12px;border:1px solid var(--border);border-radius:12px;background:#101012}
.account-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0;padding:10px 6px 10px 12px;border:1px solid var(--border);border-radius:12px;background:var(--inset)}
.account-row-main{display:grid;gap:3px;min-width:0}

View File

@ -1,4 +1,4 @@
.login-chip,.sched-chip{display:grid;gap:6px;margin:8px 0;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:#101113;max-width:min(var(--chat-col),100%)}
.login-chip,.sched-chip{display:grid;gap:6px;margin:8px 0;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--inset);max-width:min(var(--chat-col),100%)}
.login-chip .login-label,.sched-chip .sched-label{color:var(--muted);font-size:12px}
.login-chip .login-site{font-weight:600}
.login-chip .login-why{color:var(--muted);font-size:13px}
@ -12,12 +12,12 @@
.sched-row-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sched-row-main small{color:var(--muted);font-size:12px}
.sched-dot{width:8px;height:8px;border-radius:50%;background:var(--faint)}
.sched-dot.on{background:#4ade80}
.sched-dot.on{background:var(--online)}
.sched-editor{display:grid;gap:12px;min-height:0;overflow:auto}
.sched-editor-head{display:flex;align-items:center;gap:8px}
.sched-when{display:grid;gap:8px;color:var(--muted);font-size:13px}
.sched-card{display:flex;flex-wrap:wrap;gap:8px;padding:10px;border:1px solid var(--border);border-radius:12px;background:#0c0c0d}
.sched-card select,.sched-cron{height:36px;border:1px solid var(--border);border-radius:8px;background:#151517;color:var(--ink);padding:0 8px;font:inherit}
.sched-card{display:flex;flex-wrap:wrap;gap:8px;padding:10px;border:1px solid var(--border);border-radius:12px;background:var(--input)}
.sched-card select,.sched-cron{height:36px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);padding:0 8px;font:inherit}
.sched-cron{flex:1;min-width:140px;font-family:ui-monospace,monospace}
.sched-list,.sched-editor{flex:none;overflow:visible;border-top:1px solid var(--line);padding-top:16px;margin-top:6px;min-width:0}
.sched-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;padding:4px 0}

View File

@ -1,4 +1,4 @@
import { t } from "./i18n";
import { getLocale, t } from "./i18n";
export type CronFreq = "Every hour" | "Every day" | "Weekdays" | "Every week" | "Every month" | "Interval" | "Advanced";
export type CronUnit = "minutes" | "hours" | "days";
@ -31,7 +31,7 @@ export function cronFromPreset(input: CronPreset): string {
if (input.freq === "Advanced") return input.cron.trim();
if (input.freq === "Every hour") return "0 * * * *";
if (input.freq === "Interval") {
if (!Number.isInteger(input.n) || input.n < 1 || input.n > 365) throw new Error("間隔必須為 1365 的整數");
if (!Number.isInteger(input.n) || input.n < 1 || input.n > 365) throw new Error(t("schedInvalidInterval"));
return `@every ${input.n}${({minutes:"m",hours:"h",days:"d"})[input.unit]}`;
}
const { hour, minute } = parseClock(input.time);
@ -75,6 +75,59 @@ function formatClock(hour: number, minute: number): string {
return `${h12}:${String(minute).padStart(2, "0")} ${period}`;
}
export function clockLabel(time: string): string {
const { hour, minute } = parseClock(time);
if (getLocale() === "en") return formatClock(hour, minute);
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function describeCron(cron: string): string {
const preset = presetFromCron(cron);
const time = clockLabel(preset.time);
if (preset.freq === "Every hour") return t("schedEveryHour");
if (preset.freq === "Every day") return t("schedHumanDaily", { time });
if (preset.freq === "Weekdays") return t("schedHumanWeekdays", { time });
if (preset.freq === "Every week") return t("schedHumanWeekly", { time });
if (preset.freq === "Every month") return t("schedHumanMonthly", { time });
if (preset.freq === "Interval") {
const unit =
preset.unit === "hours" ? t("schedHours") : preset.unit === "days" ? t("schedDays") : t("schedMinutes");
return t("schedHumanInterval", { n: preset.n, unit });
}
return cron;
}
export function describeCronHuman(human: string): string {
const text = human.trim();
if (!text) return "";
if (text === "每小時" || text === "Every hour") return t("schedEveryHour");
if (text === "每分鐘" || text === "Every minute") return t("schedEveryMinute");
let match: RegExpExecArray | null;
if ((match = /^(?:每天|Daily at) (.+)$/.exec(text))) return t("schedHumanDaily", { time: clockLabel(match[1]) });
if ((match = /^(?:工作日|Weekdays at) (.+)$/.exec(text))) return t("schedHumanWeekdays", { time: clockLabel(match[1]) });
if ((match = /^(?:每週一|Mondays at) (.+)$/.exec(text))) return t("schedHumanWeekly", { time: clockLabel(match[1]) });
if ((match = /^(?:每月 1 日|Monthly on the 1st at) (.+)$/.exec(text))) {
return t("schedHumanMonthlyDay", { time: clockLabel(match[1]) });
}
if ((match = /^(?:每月|Monthly at) (.+)$/.exec(text))) return t("schedHumanMonthly", { time: clockLabel(match[1]) });
if ((match = /^(?:每隔 (\d+) 分鐘(固定間隔)|Every (\d+) minutes \(elapsed\))$/.exec(text))) {
return t("schedHumanElapsed", { n: match[1] || match[2] });
}
if ((match = /^(?:每 (\d+) 分鐘|Every (\d+) minutes)$/.exec(text))) {
return t("schedHumanEveryMinutes", { n: match[1] || match[2] });
}
if ((match = /^(?:每 (\d+) 小時|Every (\d+) hours)$/.exec(text))) {
return t("schedHumanEveryHours", { n: match[1] || match[2] });
}
if ((match = /^(?:日曆排程:|Calendar schedule: )(.+)$/.exec(text))) return t("schedCalendar", { expr: match[1] });
return text;
}
export function scheduleWhen(cron?: string, human?: string): string {
if (cron) return describeCron(cron);
return describeCronHuman(human || "");
}
function freqLabel(freq: CronFreq): string {
return ({
"Every hour": t("schedEveryHour"),
@ -112,7 +165,7 @@ export function ScheduleList({
<i className={`sched-dot ${item.enabled ? "on" : "off"}`} />
<span>
<strong>{item.name}</strong>
<small>{item.enabled ? item.human : t("schedPaused")}</small>
<small>{item.enabled ? describeCron(item.cron) : t("schedPaused")}</small>
</span>
</button>
<button type="button" className="outline" disabled={runningId === item.id} onClick={() => onRun(item)}>
@ -179,7 +232,7 @@ export function ScheduleEditor({
</>}
{TIMED.includes(preset.freq) && (
<select value={preset.time} onChange={e => patchPreset({ time: e.target.value })}>
{times.map(time => <option key={time} value={time}>{time}</option>)}
{times.map(time => <option key={time} value={time}>{clockLabel(time)}</option>)}
</select>
)}
{preset.freq === "Advanced" && (
@ -187,7 +240,7 @@ export function ScheduleEditor({
)}
</div>
</div>
{preset.freq === "Interval" && <small className="memory-help"> 24 </small>}
{preset.freq === "Interval" && <small className="memory-help">{t("schedIntervalHint")}</small>}
{error && <div className="pane-error">{error}</div>}
<div className="pane-actions">
{onDelete && <button type="button" className="danger" disabled={saving} onClick={onDelete}>{t("delete")}</button>}

View File

@ -1,4 +1,4 @@
:root{font-family:"Huninn","jf open 粉圓",sans-serif;color:#dfdfe2;background:#050506;font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71}
:root{font-family:"Huninn","jf open 粉圓",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;--page:#050506;--side:#0b0b0c;--main:#0d0d0e;--panel:#0a0a0b;--inset:#101012;--line:#202023;--border:#29292d;--surface:#151517;--ink:#f1f1f2;--muted:#85858a;--faint:#626267;--cream:#f1f1ef;--accent:#3ec5a8;--danger:#ef5555;--success:#4ecb71;--hairline:#171719;--elevated:#121214;--menu:#18181b;--input:#0c0c0d;--dialog:#111113;--on-cream:#1a1a1a;--on-send:#1b1b1c;--online:#22c55e;--unread:#3b82f6;--surface-hover:rgba(255,255,255,.06);--focus:#4b4b50;--error-bg:#2a1717;--error-fg:#fca5a5;--error-border:#5a2a2a;--danger-fill:#8f2828;--danger-line:#a43b3b;--danger-soft:#ff8585;--warn-bg:#221c0e;--warn-fg:#fcd68a;--warn-border:#5a4a1f}
*{box-sizing:border-box}
@ -10,7 +10,7 @@ button{color:inherit}
.app-shell{height:100%;display:grid;grid-template-columns:280px minmax(420px,1fr) minmax(340px,420px);background:var(--page);overflow:hidden}
.sidebar,.chat-panel,.computer-panel{min-width:0;min-height:0;border-right:1px solid #171719}
.sidebar,.chat-panel,.computer-panel{min-width:0;min-height:0;border-right:1px solid var(--hairline)}
.sidebar{display:flex;flex-direction:column;background:var(--side);padding:14px}
@ -28,7 +28,7 @@ button{color:inherit}
.icon-button svg{width:18px;height:18px}
.search{height:44px;display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:14px;background:#121214;padding:0 13px;color:var(--muted);margin:10px 5px 16px}
.search{height:44px;display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:14px;background:var(--elevated);padding:0 13px;color:var(--muted);margin:10px 5px 16px}
.search svg{width:15px}
@ -38,7 +38,7 @@ button{color:inherit}
.bot-row{display:flex;align-items:center;gap:12px;width:100%;padding:11px;border:0;border-radius:13px;background:transparent;text-align:left;cursor:pointer}
.bot-row:hover,.bot-row.selected{background:#171719}
.bot-row:hover,.bot-row.selected{background:var(--hairline)}
.bot-row>span:nth-child(2){min-width:0;display:grid;gap:2px}
@ -46,7 +46,7 @@ button{color:inherit}
.bot-row small{color:var(--muted)}
.sidebar-bottom{margin-top:auto;border-top:1px solid #171719;padding-top:12px}
.sidebar-bottom{margin-top:auto;border-top:1px solid var(--hairline);padding-top:12px}
.account{width:100%;display:flex;align-items:center;gap:10px;padding:8px;border:0;background:transparent;border-radius:12px}
@ -56,7 +56,7 @@ button{color:inherit}
.chat-panel{position:relative;display:flex;flex-direction:column;background:var(--main)}
.topbar{height:72px;flex:0 0 72px;padding:0 24px;gap:12px;border-bottom:1px solid #171719}
.topbar{height:72px;flex:0 0 72px;padding:0 24px;gap:12px;border-bottom:1px solid var(--hairline)}
.grow{flex:1}
@ -74,11 +74,11 @@ button{color:inherit}
.composer-plus,.send{width:42px;height:42px;border:0;border-radius:50%;display:grid;place-items:center;cursor:pointer}
.send{background:var(--cream);color:#1b1b1c}
.send{background:var(--cream);color:var(--on-send)}
.send:disabled{opacity:.28}
.error-banner{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:80%;padding:10px 14px;border:1px solid #5a2a2a;border-radius:10px;background:#2a1717;color:#fca5a5;font-size:13px}
.error-banner{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:80%;padding:10px 14px;border:1px solid var(--error-border);border-radius:10px;background:var(--error-bg);color:var(--error-fg);font-size:13px}
.error-banner button{border:0;background:none;color:inherit}
@ -88,15 +88,15 @@ button{color:inherit}
.panel-head>span{color:var(--ink);font-weight:600;margin-right:auto}
.preview{aspect-ratio:16/10;width:100%;overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#101012}
.preview{aspect-ratio:16/10;width:100%;overflow:hidden;border:1px solid var(--border);border-radius:14px;background:var(--inset)}
.primary,.outline,.danger{min-height:36px;border-radius:10px;padding:0 15px;border:1px solid var(--border);cursor:pointer;display:inline-flex;align-items:center;gap:7px}
.primary{background:var(--cream);border-color:var(--cream);color:#181819;font-weight:600}
.primary{background:var(--cream);border-color:var(--cream);color:var(--on-cream);font-weight:600}
.outline{background:transparent}
.danger{background:#8f2828;border-color:#a43b3b;color:#fff}
.danger{background:var(--danger-fill);border-color:var(--danger-line);color:#fff}
.primary:disabled,.outline:disabled{opacity:.35;cursor:not-allowed}
@ -104,7 +104,7 @@ button{color:inherit}
.modal-backdrop{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;background:rgba(0,0,0,.68);backdrop-filter:blur(8px)}
.dialog{width:min(520px,100%);display:grid;gap:20px;border:1px solid var(--border);border-radius:18px;background:#111113;padding:22px;box-shadow:0 24px 80px #000}
.dialog{width:min(520px,100%);display:grid;gap:20px;border:1px solid var(--border);border-radius:18px;background:var(--dialog);padding:22px;box-shadow:0 24px 80px #000}
.dialog.compact{width:min(430px,100%)}
@ -120,13 +120,13 @@ button{color:inherit}
.dialog label{display:grid;gap:8px;color:var(--muted);font-size:13px}
.dialog input{height:44px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
.dialog input,.dialog select{height:44px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:0 12px;outline:0}
.dialog input:focus{border-color:#4b4b50}
.dialog input:focus,.dialog select:focus{border-color:var(--focus)}
.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.mode-grid button{display:grid;justify-items:start;gap:7px;padding:16px;border:1px solid var(--border);border-radius:13px;background:#0c0c0d;text-align:left}
.mode-grid button{display:grid;justify-items:start;gap:7px;padding:16px;border:1px solid var(--border);border-radius:13px;background:var(--input);text-align:left}
.mode-grid button.picked{border-color:var(--accent);background:rgba(62,197,168,.07)}

View File

@ -421,7 +421,7 @@ async fn enqueue(state: &AppState, row: &ScheduleRow, from_tick: bool) -> Result
.bind(&thread_id)
.bind(seq)
.bind(&body)
.bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"human":describe_cron(&row.cron)}]))
.bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"cron":row.cron,"human":describe_cron(&row.cron)}]))
.bind(&run_id)
.execute(&mut *tx)
.await

View File

@ -5,7 +5,6 @@ use lazyboy_control::SandboxProvider;
use lazyboy_sandbox::{DockerSandbox, FakeSandbox};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use tokio::sync::Mutex;
use crate::auth::AuthConfig;
use crate::db::{Actor, Db};
@ -14,23 +13,42 @@ use crate::memory::MemoryService;
#[derive(Clone, Default)]
pub struct CallRegistry {
inner: Arc<Mutex<HashMap<String, String>>>,
inner: Arc<std::sync::Mutex<HashMap<String, String>>>,
}
impl CallRegistry {
pub async fn try_begin(&self, bot_id: &str, call_id: &str) -> bool {
let mut map = self.inner.lock().await;
/// Returns a lease that releases the slot when dropped, so a websocket
/// upgrade that never completes cannot leave the bot marked as busy.
pub fn try_begin(&self, bot_id: &str, call_id: &str) -> Option<CallLease> {
let mut map = self.lock();
if map.contains_key(bot_id) {
return false;
return None;
}
map.insert(bot_id.to_string(), call_id.to_string());
true
drop(map);
Some(CallLease {
registry: self.clone(),
bot_id: bot_id.to_string(),
call_id: call_id.to_string(),
})
}
pub async fn end(&self, bot_id: &str, call_id: &str) {
let mut map = self.inner.lock().await;
if map.get(bot_id).is_some_and(|held| held == call_id) {
map.remove(bot_id);
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
self.inner.lock().unwrap_or_else(|error| error.into_inner())
}
}
pub struct CallLease {
registry: CallRegistry,
bot_id: String,
call_id: String,
}
impl Drop for CallLease {
fn drop(&mut self) {
let mut map = self.registry.lock();
if map.get(&self.bot_id).is_some_and(|held| *held == self.call_id) {
map.remove(&self.bot_id);
}
}
}

View File

@ -42,18 +42,17 @@ pub async fn call_ws(
};
match prepare_call(&state, &actor, &session_id).await {
Ok(prep) => {
if !state.calls.try_begin(&prep.bot_id, &prep.call_id).await {
let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else {
return (
StatusCode::CONFLICT,
Json(json!({"message":"already on a call"})),
)
.into_response();
}
};
let provider = create_voice(prep.provider);
let socket = match provider.connect(prep.connect.clone()).await {
Ok(socket) => socket,
Err(error) => {
state.calls.end(&prep.bot_id, &prep.call_id).await;
return (
StatusCode::BAD_GATEWAY,
Json(json!({"message": error.to_string()})),
@ -61,13 +60,28 @@ pub async fn call_ws(
.into_response();
}
};
ws.on_upgrade(move |client| run_call(state, actor, session_id, prep, client, socket))
ws.on_upgrade(move |client| {
run_call(state, actor, session_id, prep, client, socket, lease)
})
.into_response()
}
Err((status, message)) => (status, Json(json!({"message": message}))).into_response(),
}
}
/// The client asks for a barge-in over the text channel; audio frames stay binary.
fn is_interrupt_request(text: &str) -> bool {
serde_json::from_str::<Value>(text)
.ok()
.and_then(|value| {
value
.get("type")
.and_then(Value::as_str)
.map(str::to_string)
})
.is_some_and(|kind| kind == "interrupt")
}
async fn prepare_call(
state: &AppState,
actor: &Actor,
@ -138,6 +152,7 @@ async fn run_call(
prep: PreparedCall,
client: WebSocket,
mut provider: Box<dyn VoiceSocket>,
_lease: crate::state::CallLease,
) {
let call_id = prep.call_id.clone();
let bot_id = prep.bot_id.clone();
@ -159,9 +174,14 @@ async fn run_call(
let mut user_partial = String::new();
let mut assistant_partial = String::new();
let mut last_progress = String::new();
let mut response_active = false;
let mut last_spoken_at = std::time::Instant::now()
.checked_sub(Duration::from_secs(30))
.unwrap_or_else(std::time::Instant::now);
// A fresh `sleep` inside `select!` would be cancelled by every microphone
// frame (one every few milliseconds), so the poll must own its own timer.
let mut watchdog = tokio::time::interval(Duration::from_millis(800));
watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
@ -178,7 +198,14 @@ async fn run_call(
break;
}
}
Some(Ok(AxumMessage::Text(_))) => {}
Some(Ok(AxumMessage::Text(text))) => {
if is_interrupt_request(&text) && response_active {
response_active = false;
if provider.send(VoiceEvent::CancelResponse).await.is_err() {
break;
}
}
}
}
}
provider_msg = provider.recv() => {
@ -195,6 +222,7 @@ async fn run_call(
event,
&mut user_partial,
&mut assistant_partial,
&mut response_active,
)
.await
.is_err()
@ -204,7 +232,7 @@ async fn run_call(
}
}
}
_ = tokio::time::sleep(Duration::from_millis(800)) => {
_ = watchdog.tick() => {
let _ = watch_computer(
&state,
&actor,
@ -220,8 +248,6 @@ async fn run_call(
}
}
}
state.calls.end(&bot_id, &call_id).await;
}
async fn handle_provider_event(
@ -234,9 +260,15 @@ async fn handle_provider_event(
event: VoiceEvent,
user_partial: &mut String,
assistant_partial: &mut String,
response_active: &mut bool,
) -> Result<(), ()> {
match event {
VoiceEvent::ResponseStarted => *response_active = true,
VoiceEvent::ResponseFinished => *response_active = false,
VoiceEvent::AudioPcm(bytes) => {
// Not every provider announces `response.created`; flowing audio is
// proof enough that there is something to cancel on a barge-in.
*response_active = true;
client_write
.lock()
.await
@ -277,6 +309,7 @@ async fn handle_provider_event(
}
VoiceEvent::OutputTranscript { text, final_ } => {
if final_ {
*response_active = false;
let body = if text.trim().is_empty() {
assistant_partial.trim().to_string()
} else {
@ -599,9 +632,17 @@ async fn send_json(
#[cfg(test)]
mod tests {
use super::speakable_progress;
use super::{is_interrupt_request, speakable_progress};
use serde_json::json;
#[test]
fn only_a_typed_interrupt_frame_cancels_the_reply() {
assert!(is_interrupt_request(r#"{"type":"interrupt"}"#));
assert!(!is_interrupt_request(r#"{"type":"hangup"}"#));
assert!(!is_interrupt_request("interrupt"));
assert!(!is_interrupt_request(""));
}
#[test]
fn takeover_and_idle_after_work_are_spoken_once() {
let takeover = json!({"status":"takeover","takeover":true});

View File

@ -127,6 +127,9 @@ pub enum VoiceEvent {
SpeakNow { text: String },
InjectContext { text: String },
ResponseCreate,
ResponseStarted,
ResponseFinished,
CancelResponse,
Error { message: String },
}
@ -374,6 +377,9 @@ pub fn encode_provider_event(provider: VoiceProvider, event: &VoiceEvent) -> Opt
VoiceEvent::ResponseCreate => Some(Message::Text(
json!({ "type": "response.create" }).to_string().into(),
)),
VoiceEvent::CancelResponse => Some(Message::Text(
json!({ "type": "response.cancel" }).to_string().into(),
)),
VoiceEvent::InjectContext { text } => Some(Message::Text(
json!({
"type": "conversation.item.create",
@ -421,6 +427,10 @@ pub fn parse_provider_event(text: &str) -> Option<VoiceEvent> {
match kind {
"input_audio_buffer.speech_started" => Some(VoiceEvent::SpeechStarted),
"input_audio_buffer.speech_stopped" => Some(VoiceEvent::SpeechStopped),
"response.created" => Some(VoiceEvent::ResponseStarted),
"response.done" | "response.completed" | "response.cancelled" => {
Some(VoiceEvent::ResponseFinished)
}
"conversation.item.input_audio_transcription.delta"
| "response.input_audio_transcription.delta" => {
let text = event.get("delta")?.as_str()?.to_string();

View File

@ -3,9 +3,19 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import ts from '../apps/web/node_modules/typescript/lib/typescript.js';
function loadCatalog(file){
const code=ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const sandbox={exports:{},require:()=>({})};
vm.runInNewContext(code,sandbox);
return sandbox.exports.zhTW||sandbox.exports.en;
}
const zhTW=loadCatalog('apps/web/src/locales/zh-TW.ts');
const en=loadCatalog('apps/web/src/locales/en.ts');
const catalogs={'zh-TW':zhTW,en};
const i18n={locale:'en',t:(key,params)=>{const message=catalogs[i18n.locale]?.[key]??key;return params?message.replace(/\{(\w+)\}/g,(m,name)=>Object.hasOwn(params,name)?String(params[name]):m):message},getLocale:()=>i18n.locale};
const raw=fs.readFileSync('apps/web/src/schedule.tsx','utf8');
const js=ts.transpileModule(raw,{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText;
const box={exports:{},require:()=>({t:x=>x})};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset}=box.exports;
const box={exports:{},require:()=>i18n};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset,describeCron,describeCronHuman}=box.exports;
test('fixed intervals retain exact elapsed units',()=>{
for (const unit of ['minutes','hours','days']) {const p={...defaultCronPreset(),freq:'Interval',n:45,unit};const cron=cronFromPreset(p);const restored=presetFromCron(cron);assert.equal(restored.n,45);assert.equal(restored.unit,unit);}
});
@ -46,7 +56,14 @@ const {sanitizeMarkdownUrl}=mdBox.exports;
const audioJs=ts.transpileModule(fs.readFileSync('apps/web/src/call-audio.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const audioBox={exports:{},require:()=>{throw new Error('unexpected import')}};
vm.runInNewContext(audioJs,audioBox);
const {floatToPcm16,pcm16ToFloat,resample,VOICE_SAMPLE_RATE}=audioBox.exports;
const {floatToPcm16,pcm16ToFloat,resample,VOICE_SAMPLE_RATE,CAPTURE_CHUNK_SAMPLES}=audioBox.exports;
function fakePlaybackContext(rendered){
return {
sampleRate:VOICE_SAMPLE_RATE,currentTime:0,
createBuffer:(_channels,length,rate)=>{const data=new Float32Array(length);rendered.push(data);return {length,duration:length/rate,getChannelData:()=>data}},
createBufferSource:()=>({buffer:null,connect(){},start(){},stop(){}}),
};
}
test('pcm16 roundtrip keeps amplitude sign and resample identity at 24 kHz',()=>{
const source=new Float32Array([0,0.5,-0.5,1,-1]);
const bytes=floatToPcm16(source);
@ -68,6 +85,29 @@ test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
});
test('zh-TW and en catalogs share keys and placeholders',()=>{
const zhKeys=Object.keys(zhTW).sort();
const enKeys=Object.keys(en).sort();
assert.deepEqual(enKeys,zhKeys);
for(const key of zhKeys){
const slots=s=>[...String(s).matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort();
assert.deepEqual(slots(en[key]),slots(zhTW[key]),key);
assert.notEqual(String(en[key]).trim(),'');
assert.notEqual(String(zhTW[key]).trim(),'');
}
});
test('describeCron and stored Chinese human strings follow the UI locale',()=>{
i18n.locale='en';
assert.equal(describeCron('0 9 * * *'),'Daily at 9:00 AM');
assert.equal(describeCron('0 9 * * 1-5'),'Weekdays at 9:00 AM');
assert.equal(describeCronHuman('每天 09:00'),'Daily at 9:00 AM');
assert.equal(describeCronHuman('每隔 45 分鐘(固定間隔)'),'Every 45 minutes (elapsed)');
i18n.locale='zh-TW';
assert.equal(describeCron('0 9 * * *'),'每天 09:00');
assert.equal(describeCronHuman('Daily at 9:00 AM'),'每天 09:00');
});
test('hanging up before microphone permission resolves releases the late stream',async()=>{
let grant; let stopped=0;
const context={exports:{},navigator:{mediaDevices:{getUserMedia:()=>new Promise(resolve=>grant=resolve)}}};
@ -79,3 +119,28 @@ test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
await starting;
assert.equal(stopped,1);
});
test('odd-sized audio frames stay sample-aligned instead of turning into noise',()=>{
const rendered=[];
const audio=new audioBox.exports.CallAudio({onCapture(){},onError(){}});
audio.context=fakePlaybackContext(rendered);
const source=new Int16Array([1000,-1000,2000,-2000,3000,-3000,4000,-4000]);
const bytes=new Uint8Array(source.buffer);
let offset=0;
for(const size of [3,5,7,1]){audio.play(bytes.slice(offset,offset+size).buffer);offset+=size}
const played=rendered.flatMap(chunk=>[...chunk]);
const expected=[...pcm16ToFloat(bytes.slice().buffer)];
assert.equal(played.length,expected.length);
expected.forEach((value,index)=>assert.ok(Math.abs(played[index]-value)<1e-6,`sample ${index}`));
});
test('microphone frames are batched instead of sent one websocket message per block',()=>{
const sent=[];
const audio=new audioBox.exports.CallAudio({onCapture:pcm=>sent.push(pcm.length),onError(){}});
const block=new Float32Array(64);
const blocksPerChunk=CAPTURE_CHUNK_SAMPLES/block.length;
for(let i=0;i<blocksPerChunk-1;i++)audio.queueCapture(block);
assert.deepEqual(sent,[]);
audio.queueCapture(block);
assert.deepEqual(sent,[CAPTURE_CHUNK_SAMPLES*2]);
});