From 3a854bfa3db08362c87966cb678e8c323f95f2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 9 Sep 2026 01:14:29 +0800 Subject: [PATCH] fix cua --- .gitignore | 1 + Makefile | 21 +- apps/web/src/App.tsx | 105 +++- apps/web/src/chat.css | 2 +- apps/web/src/live.ts | 6 + apps/web/src/locales/en.ts | 28 + apps/web/src/locales/zh-TW.ts | 28 + apps/web/src/refinements.css | 18 + apps/web/src/reply-stream.ts | 30 + apps/web/src/run-monitor.tsx | 47 +- apps/web/src/styles.css | 3 +- apps/web/src/types.ts | 6 +- crates/api/src/memory.rs | 707 ++++++++++++++++++++-- crates/api/src/monitor.rs | 151 ++++- crates/api/src/runs.rs | 230 ++++++- crates/api/src/sessions.rs | 6 + crates/api/src/state.rs | 5 +- crates/control/src/cua/browser.rs | 41 +- crates/control/src/cua/client.rs | 133 +++- crates/controld/src/main.rs | 4 + docs/interaction-audit.md | 269 ++++++++ image/computer/Dockerfile | 4 + image/computer/cua-browser-cursor.patch | 47 ++ image/computer/cua-browser-cursor.rs | 121 ++++ migrations/016_memory_room_scope.sql | 47 ++ migrations/017_memory_embedding_model.sql | 6 + scripts/cua-observe-http-test.py | 58 ++ scripts/cua-smoke-test.sh | 4 + scripts/reply-stream-test.mjs | 33 + 29 files changed, 2055 insertions(+), 106 deletions(-) create mode 100644 apps/web/src/reply-stream.ts create mode 100644 docs/interaction-audit.md create mode 100644 image/computer/cua-browser-cursor.patch create mode 100644 image/computer/cua-browser-cursor.rs create mode 100644 migrations/016_memory_room_scope.sql create mode 100644 migrations/017_memory_embedding_model.sql create mode 100644 scripts/cua-observe-http-test.py create mode 100644 scripts/reply-stream-test.mjs diff --git a/.gitignore b/.gitignore index e137c3b..3e35d25 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .env.* !.env.example /data +models--*/ image/computer/lazyboy-controld node_modules dist diff --git a/Makefile b/Makefile index 43ec085..e7dc9c2 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ $(if $(filter 1,$(PUSH)),$(eval MULTI_FLAGS := --push)) .PHONY: help env env-force \ up logs ps health down purge \ computer computer-multi images-multi postgres postgres-down pg-collation \ - cua-smoke \ + cua-smoke prepare-screen-network \ build build-api build-supervisor build-controld \ fmt fmt-check clippy lint audit test clean \ web \ @@ -76,11 +76,30 @@ env-force: ## Refuse destructive key regeneration; existing vault keys must be p up: env ## Build every image and start the whole stack in Docker @echo "building images + starting stack (first run is slow: builds desktop + rust + web)..." + @$(MAKE) --no-print-directory prepare-screen-network $(COMPOSE) up -d --build @echo "" @echo "stack launched. open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN." $(COMPOSE) ps +# Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or +# an older supervisor has empty labels, and Compose v2+ then fails after the +# image build. Recreate only when nothing is attached. +prepare-screen-network: + @set -a; [ -f .env ] && . ./.env; set +a; \ + network="$${LAZYBOY_SCREEN_NETWORK:-lazyboy_screen}"; \ + if ! docker network inspect "$$network" >/dev/null 2>&1; then exit 0; fi; \ + label=$$(docker network inspect -f '{{index .Labels "com.docker.compose.network"}}' "$$network"); \ + if [ "$$label" = "screen" ]; then exit 0; fi; \ + count=$$(docker network inspect -f '{{len .Containers}}' "$$network"); \ + if [ "$$count" != "0" ]; then \ + echo "error: Docker network $$network exists without Compose labels and still has $$count container(s)." >&2; \ + echo "Stop those containers, then: docker network rm $$network" >&2; \ + exit 1; \ + fi; \ + echo "removing leftover Docker network $$network so Compose can recreate it"; \ + docker network rm "$$network" + logs: ## Tail logs for all services $(COMPOSE) logs -f diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b17acf9..fadaa74 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,10 +16,11 @@ import visibility2 from "react-useanimations/lib/visibility2"; import searchToX from "react-useanimations/lib/searchToX"; import { api, ApiError } from "./api"; import { createCoalescer, subscribeToSession } from "./live"; +import { applyReplyEvent, type ReplyDrafts } from "./reply-stream"; import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; -import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; +import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; import { ChatMarkdown, CopyMessageButton } from "./markdown"; import { RunProbe, errorActions, errorTitle } from "./run-monitor"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule"; @@ -163,6 +164,10 @@ export function App(){ const [scheduleError,setScheduleError]=useState(null); const [scheduleSaving,setScheduleSaving]=useState(false); const [runningScheduleId,setRunningScheduleId]=useState(null); const [workspaceName,setWorkspaceName]=useState(workspaceStart.name); const [looks,setLooks]=useState(readAvatarLooks); + const pendingSendNonces=useRef(new Map()); + const currentSessionRef=useRef(activeSessionId);currentSessionRef.current=activeSessionId; + const [sendingMessage,setSendingMessage]=useState(false); + const [replyDrafts,setReplyDrafts]=useState({}); const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef(null); const attachRef=useRef(null); const desktopFrameRef=useRef(null); const holderRef=useRef(computer.controlHolder); const paneBotRef=useRef(null); const skipHandoffRef=useRef(true); @@ -180,6 +185,13 @@ export function App(){ const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]); const filteredRooms=useMemo(()=>{const q=query.trim().toLowerCase();return rooms.filter(room=>!q||room.name.toLowerCase().includes(q)||room.members.some(member=>member.name.toLowerCase().includes(q)))},[rooms,query]); const sections=useMemo(()=>{const map=new Map();for(const bot of filtered){const key=bot.pinned?t("pinned"):bot.groupName||t("agentGroup");map.set(key,[...(map.get(key)||[]),bot])}return [...map.entries()]},[filtered]); + const[memoryTargets,setMemoryTargets]=useState>({}); + const[pendingMemory,setPendingMemory]=useState<{message:Message;sessionId:string|null;members:RoomMember[]}|null>(null); + const[memorySaveBusy,setMemorySaveBusy]=useState(false); + const savingMemoryRef=useRef(false); + useEffect(()=>setPendingMemory(null),[activeSessionId]); + const memoryTargetId=activeRoom?(activeRoom.members.find(member=>member.id===memoryTargets[activeRoom.id])?.id||activeRoom.members[0]?.id):active?.id; + const memoryBot=bots.find(bot=>bot.id===memoryTargetId); const paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId; const paneBot=bots.find(bot=>bot.id===paneBotId)||active; const currentPaneRef=useRef(paneBotId);currentPaneRef.current=paneBotId; @@ -216,17 +228,21 @@ export function App(){ const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null; const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]); const refresh=useCallback(async()=>{if(!activeSessionId)return;const refreshSeq=++refreshSeqRef.current;let nextBusy:RoomMember[]=[]; + // Transcript delivery is independent of desktop health and screen discovery. + // Resolve errors as values until joined below, so an early rejection cannot + // become unhandled while the computer request is still in flight. + const messagesJob=api(`/api/sessions/${activeSessionId}/messages`).then(next=>{if(refreshSeq===refreshSeqRef.current)setMessages(next);return null},error=>error); let computerBot=activeId; if(activeRoomId){ try{const roomStatus=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);if(refreshSeq!==refreshSeqRef.current)return;nextBusy=roomStatus.busy;computerBot=roomStatus.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{if(refreshSeq!==refreshSeqRef.current)return;computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null} } - const messagesJob=api(`/api/sessions/${activeSessionId}/messages`); - if(!computerBot){const nextMessages=await messagesJob;if(refreshSeq===refreshSeqRef.current){setBusyMembers(nextBusy);setMessages(nextMessages);setComputer(blankComputer);setSkills([]);setScreenUrl(null)}return} + if(!computerBot){const messageError=await messagesJob;if(messageError)throw messageError;if(refreshSeq===refreshSeqRef.current){setBusyMembers(nextBusy);setComputer(blankComputer);setSkills([]);setScreenUrl(null)}return} const skillsJob=activeRoomId?Promise.resolve([] as TaughtSkill[]):api(`/api/bots/${computerBot}/skills`).catch(()=>[] as TaughtSkill[]); const status=await api(`/api/computer/${computerBot}/status`);if(refreshSeq!==refreshSeqRef.current)return; - const [nextMessages,screen,nextSkills]=await Promise.all([messagesJob,status.state==="running"?api<{url:string|null}>(`/api/computer/${computerBot}/screen`).catch(()=>({url:null})):Promise.resolve({url:null}),skillsJob]); + const [messageError,screen,nextSkills]=await Promise.all([messagesJob,status.state==="running"?api<{url:string|null}>(`/api/computer/${computerBot}/screen`).catch(()=>({url:null})):Promise.resolve({url:null}),skillsJob]); if(refreshSeq!==refreshSeqRef.current)return; - setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills); + if(messageError)throw messageError; + setBusyMembers(nextBusy);setComputer(status);setSkills(nextSkills); // A missing url is not a dead desktop. While it boots or wakes the mounted // frame keeps its VNC session instead of blacking out and reconnecting. setScreenUrl(current=>status.botId===computerBot?keepScreenUrl(current,screen.url,status.state):null); @@ -247,6 +263,7 @@ export function App(){ 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(()=>{const panel=messageEndRef.current?.parentElement;if(panel&&panel.scrollHeight-panel.scrollTop-panel.clientHeight<180)panel.scrollTop=panel.scrollHeight},[replyDrafts]); useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]); // Live transcript: the event stream says when to look, so a reply appears as // soon as it is written. Polling stays as a backstop that cannot starve the @@ -254,6 +271,7 @@ export function App(){ // foreground - which is itself a "what happened" moment worth refreshing on. useEffect(()=>{ if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return} + setReplyDrafts({}); setScreenUrl(null); refresh().catch(e=>setError(localizeError(e.message))); const settle=createCoalescer(()=>{if(!document.hidden)refresh().catch(()=>{})},EVENT_SETTLE_MS); @@ -261,7 +279,7 @@ export function App(){ const tick=()=>{if(!document.hidden)refresh().catch(()=>{});timer=window.setTimeout(tick,live?LIVE_POLL_MS:FALLBACK_POLL_MS)}; timer=window.setTimeout(tick,FALLBACK_POLL_MS); const heartbeat=window.setInterval(()=>{const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},HEARTBEAT_MS); - const feed=subscribeToSession(activeSessionId,()=>settle.kick(),{onStatus:connected=>{live=connected;if(connected&&opened)refresh().catch(()=>{});opened=true}}); + const feed=subscribeToSession(activeSessionId,event=>{setReplyDrafts(current=>applyReplyEvent(current,event));if(!event.kind.startsWith("reply."))settle.kick()},{onStatus:connected=>{live=connected;if(connected&&opened)refresh().catch(()=>{});opened=true}}); const resume=()=>{if(!document.hidden)refresh().catch(()=>{})}; document.addEventListener("visibilitychange",resume); return()=>{window.clearTimeout(timer);window.clearInterval(heartbeat);document.removeEventListener("visibilitychange",resume);feed.close();settle.cancel();refreshSeqRef.current+=1}; @@ -320,7 +338,33 @@ export function App(){ async function importSkillFile(file:File){if(!active)return;await action(async()=>{let payload:unknown;try{payload=JSON.parse(await file.text())}catch{throw new Error(t("skillImportInvalid"))}const skill=await api(`/api/bots/${active.id}/skills/import`,{method:"POST",body:JSON.stringify(payload)});setEditingSkillId(skill.id)})} function addPendingFiles(list:FileList|File[]){const incoming=[...list];if(!incoming.length)return;setError(null);setPendingFiles(current=>{const next=[...current];for(const file of incoming){if(next.length>=ATTACH_MAX){setError(t("attachTooMany"));break}if(file.size>ATTACH_MAX_BYTES){setError(t("attachTooLarge"));continue}if(!attachAllowed(file)){setError(t("attachType"));continue}next.push({id:clientNonce(),file,preview:file.type.startsWith("image/")?URL.createObjectURL(file):null})}return next})} function removePendingFile(id:string){setPendingFiles(current=>current.filter(item=>{if(item.id===id&&item.preview)URL.revokeObjectURL(item.preview);return item.id!==id}))} - async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();const files=pendingFiles;if((!active&&!activeRoom)||!activeSessionId||(!text&&files.length===0))return;sendingRef.current=true;setDraft("");setPendingFiles([]);historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{try{const attachments=await Promise.all(files.map(async item=>({name:item.file.name,mimeType:item.file.type,content:await readAsBase64(item.file)})));await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce(),attachments})});sentHistoryRef.current.push(text||files[0]?.file.name||"");if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();files.forEach(item=>item.preview&&URL.revokeObjectURL(item.preview));await loadSessions()}catch(error){setPendingFiles(files);throw error}})}finally{sendingRef.current=false}} + async function send(event:FormEvent){ + event.preventDefault(); + if(sendingRef.current||busy)return; + const submittedDraft=draft,text=submittedDraft.trim(),files=pendingFiles,sessionId=activeSessionId; + if((!active&&!activeRoom)||!sessionId||(!text&&files.length===0))return; + // An uncertain HTTP result must reuse the original nonce on retry. Keep + // the user's input until acknowledgement; a failed refresh is not a failed send. + const key=JSON.stringify([sessionId,text,files.map(file=>file.id)]); + const nonce=pendingSendNonces.current.get(key)||clientNonce(); + pendingSendNonces.current.set(key,nonce); + sendingRef.current=true;setSendingMessage(true); + try{await action(async()=>{ + 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/${sessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:nonce,attachments})}); + pendingSendNonces.current.delete(key); + if(currentSessionRef.current===sessionId){ + setDraft(current=>current===submittedDraft?"":current); + setPendingFiles(current=>current.filter(item=>!files.some(sent=>sent.id===item.id))); + historyIndexRef.current=null;historyDraftRef.current=""; + } + 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(); + })}finally{sendingRef.current=false;setSendingMessage(false)} + } + function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} @@ -331,7 +375,16 @@ export function App(){ async function retryRun(runId?:string){if(!runId||busy)return;await action(()=>api(`/api/runs/${runId}/retry`,{method:"POST",body:"{}"}));setRetriedRuns(current=>({...current,[runId]:true}))} async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})} async function deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} - async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?localizeError(e.message):t("rememberFailed"))}} + async function saveMessageMemory(message:Message,botId:string,sessionId:string|null){ + if(savingMemoryRef.current||!message.body.trim())return; + savingMemoryRef.current=true;setMemorySaveBusy(true); + try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId,sourceMessageId:message.id})});setRemembered(current=>({...current,[message.id]:true}));setPendingMemory(null)}catch(e){setError(e instanceof Error?localizeError(e.message):t("rememberFailed"))}finally{savingMemoryRef.current=false;setMemorySaveBusy(false)} + } + async function rememberMessage(message:Message){ + if(activeRoom&&!message.speakerBotId){setPendingMemory({message,sessionId:activeSessionId,members:activeRoom.members});return} + const botId=message.speakerBotId||active?.id; + if(botId)await saveMessageMemory(message,botId,activeSessionId); + } function pasteText(text:string){queueDesktopInput({kind:"clipboard",text})} function queueDesktopInput(input:{kind:"clipboard";text:string}|{kind:"key";key:string}){ const botId=paneBotId; @@ -474,12 +527,12 @@ export function App(){
{activeRoom?<>member.id)}/>{activeRoom.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:active?<>{active.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:<>{t("chooseBot")}{topTools}}
{callOpen&&voiceSettings?.enabled&&active&&activeSessionId&&!activeRoomId?setCallOpen(false)} onTakeOver={()=>{setRightPart("computer");setRightCollapsed(false);setComputerOpen(true);if(active&&!computer.sharedInput)void setControl("user",active.id)}}/>:null} -
{(activeRoom||active)&&messages.length===0?
{activeRoom?:}

{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}

{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}

: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
{spoken&&}{spoken&&{speakerName}}{files.length>0&&
{files.map(file=>)}
}{chips.map((chip,index)=>chip.kind==="error"?
{errorTitle(chip.code)}
{errorActions(chip.code).map(next=>next==="retry"?:next==="screen"?:)}
:chip.kind==="resume"?
{resumeTitle(chip.reason)}
{(chip.limit||0)>0?
{t("resumeProgress",{turns:chip.turns||0,limit:chip.limit||0})}
:null}{resumedChips[message.id]||(message.seq??0)
}
:chip.kind==="login"?
{t("loginNeedsYou")}
{chip.site||message.body}
{chip.why?
{t("loginWhy",{why:chip.why})}
:null}
:chip.kind==="schedule"?
{t("scheduleChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
:
{t("scheduleRunChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
)}{!hideBody&&(message.role==="assistant"?
{message.body}
{message.body.trim()?:null}
:{message.body})}{!hideBody&&message.body.trim()&&}
})}{pausedForUser&&paneBot&&
{computer.sharedInput?t("sharedNeedsUser",{name:paneBot.name}):computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}}{computer.sharedInput||computer.controlHolder==="user"?:}
}{teaching&&active&&
{t("teachingLive",{goal:teaching.goal})}{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}}
}{drafting&&
{t("distilling",{goal:drafting.goal})}
}{skillDraft&&active&&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)}/>} +
{(activeRoom||active)&&messages.length===0?
{activeRoom?:}

{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}

{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}

: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
{spoken&&}{spoken&&{speakerName}}{files.length>0&&
{files.map(file=>)}
}{chips.map((chip,index)=>chip.kind==="error"?
{errorTitle(chip.code)}
{errorActions(chip.code).map(next=>next==="retry"?:next==="screen"?:)}
:chip.kind==="resume"?
{resumeTitle(chip.reason)}
{(chip.limit||0)>0?
{t("resumeProgress",{turns:chip.turns||0,limit:chip.limit||0})}
:null}{resumedChips[message.id]||(message.seq??0)
}
:chip.kind==="login"?
{t("loginNeedsYou")}
{chip.site||message.body}
{chip.why?
{t("loginWhy",{why:chip.why})}
:null}
:chip.kind==="schedule"?
{t("scheduleChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
:
{t("scheduleRunChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
)}{!hideBody&&(message.role==="assistant"?
{message.body}
{message.body.trim()?:null}{message.runId&&{t("messageRunDetails")}}
:{message.body})}{!hideBody&&message.body.trim()&&}
})}{pausedForUser&&paneBot&&
{computer.sharedInput?t("sharedNeedsUser",{name:paneBot.name}):computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}}{computer.sharedInput||computer.controlHolder==="user"?:}
}{teaching&&active&&
{t("teachingLive",{goal:teaching.goal})}{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}}
}{drafting&&
{t("distilling",{goal:drafting.goal})}
}{skillDraft&&active&&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)}/>}{Object.values(replyDrafts).filter(reply=>reply.text&&(!reply.messageId||!messages.some(message=>message.id===reply.messageId))).map(reply=>{const speaker=bots.find(bot=>bot.id===reply.botId);return
{activeRoom&&speaker&&<>{speaker.name}}
{reply.text}
})}
{error&&
{error}
} {otherSessionBusy&&
{t("anotherConversationQueued")}
} {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
{label}{!transition&&step?{localizeStep(step)}:null}
})} -
{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}>
event.stopPropagation()}>{plusOpen&&
{savedSkills.length>0&&<>
{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}{savedSkills.length>=6&&setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}
{listedSkills.map(skill=>
)}{listedSkills.length===0&&{t("noMatchingSkills")}}
}
}
{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&
{pendingFiles.map(item=>removePendingFile(item.id)}/>)}
}{slashSuggestions.length>0&&
{slashSuggestions.map((skill,index)=>)}
}