add new feat

This commit is contained in:
王性驊 2026-09-04 17:57:33 +08:00
parent 7d87dd5b3c
commit f56a2d86b6
4 changed files with 107 additions and 22 deletions

View File

@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Ellipsis, Info, LogOut, Megaphone, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Square, Users, X } from "./animated-icons"; import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Ellipsis, Info, LogOut, Megaphone, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Square, Users, X } from "./animated-icons";
import UseAnimations from "react-useanimations"; import UseAnimations from "react-useanimations";
import loading from "react-useanimations/lib/loading"; import loading from "react-useanimations/lib/loading";
@ -69,7 +69,9 @@ export function App(){
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null); const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name); const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
const [looks,setLooks]=useState(readAvatarLooks); const [looks,setLooks]=useState(readAvatarLooks);
const sendingRef=useRef(false); const refreshingRef=useRef(false); const sendingRef=useRef(false); const refreshSeqRef=useRef(0);
const messageEndRef=useRef<HTMLDivElement|null>(null);
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
const roomsRef=useRef(rooms); const roomsRef=useRef(rooms);
roomsRef.current=rooms; roomsRef.current=rooms;
const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]); const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]);
@ -78,25 +80,31 @@ export function App(){
const paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId; const paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId;
const paneBot=bots.find(bot=>bot.id===paneBotId)||active; const paneBot=bots.find(bot=>bot.id===paneBotId)||active;
const workingMembers=activeRoom?busyMembers:active&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[]; const workingMembers=activeRoom?busyMembers:active&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[];
const lastMessageId=messages[messages.length-1]?.id||"";
const loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]); const loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]);
const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api<Bot[]>("/api/bots"),api<Room[]>("/api/rooms").catch(()=>[] as Room[])]);setBots(next);setRooms(nextRooms);setActiveRoomId(id=>id&&nextRooms.some(room=>room.id===id)?id:null);setActiveId(id=>id&&next.some(b=>b.id===id)?id:null);await loadMcp()},[loadMcp]); const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api<Bot[]>("/api/bots"),api<Room[]>("/api/rooms").catch(()=>[] as Room[])]);setBots(next);setRooms(nextRooms);setActiveRoomId(id=>id&&nextRooms.some(room=>room.id===id)?id:null);setActiveId(id=>id&&next.some(b=>b.id===id)?id:null);await loadMcp()},[loadMcp]);
const sessionStoreKey=activeRoomId?`room:${activeRoomId}`:activeId; const sessionStoreKey=activeRoomId?`room:${activeRoomId}`:activeId;
const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null; const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null;
const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]); const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]);
const refresh=useCallback(async()=>{if(!activeSessionId||refreshingRef.current)return;refreshingRef.current=true;try{const messagesJob=api<Message[]>(`/api/sessions/${activeSessionId}/messages`).then(setMessages); const refresh=useCallback(async()=>{if(!activeSessionId)return;const refreshSeq=++refreshSeqRef.current;let nextBusy:RoomMember[]=[];
let computerBot=activeId; let computerBot=activeId;
if(activeRoomId){ if(activeRoomId){
try{const status=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);setBusyMembers(status.busy);computerBot=status.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{setBusyMembers([]);computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null} 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}
}else setBusyMembers([]); }
if(!computerBot){await messagesJob;return} const messagesJob=api<Message[]>(`/api/sessions/${activeSessionId}/messages`);
const status=await api<ComputerStatus>(`/api/computer/${computerBot}/status`);setComputer(status);await messagesJob; if(!computerBot){const nextMessages=await messagesJob;if(refreshSeq===refreshSeqRef.current){setBusyMembers(nextBusy);setMessages(nextMessages);setComputer(blankComputer);setScreenUrl(null)}return}
if(status.state==="running")await api<{url:string|null}>(`/api/computer/${computerBot}/screen`).then(screen=>setScreenUrl(screen.url)).catch(()=>setScreenUrl(null));else setScreenUrl(null) const status=await api<ComputerStatus>(`/api/computer/${computerBot}/status`);if(refreshSeq!==refreshSeqRef.current)return;
}finally{refreshingRef.current=false}},[activeId,activeRoomId,activeSessionId]); const [nextMessages,screen]=await Promise.all([messagesJob,status.state==="running"?api<{url:string|null}>(`/api/computer/${computerBot}/screen`).catch(()=>({url:null})):Promise.resolve({url:null})]);
if(refreshSeq!==refreshSeqRef.current)return;
setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);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(e.message)})},[loadBots]);
useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]); 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(e.message))},[loadSessions]);
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){setMessages([]);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}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)},[activeId,activeRoomId,activeSessionId,refresh]); useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current=""},[activeSessionId]);
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length]);
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.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)}); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
@ -112,7 +120,8 @@ export function App(){
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?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 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 send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();if((!active&&!activeRoom)||!activeSessionId||!text)return;sendingRef.current=true;setDraft("");try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce()})});await loadSessions()})}finally{sendingRef.current=false}} async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();if((!active&&!activeRoom)||!activeSessionId||!text)return;sendingRef.current=true;setDraft("");historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce()})});sentHistoryRef.current.push(text);if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();await loadSessions()})}finally{sendingRef.current=false}}
function composerKeyDown(event:ReactKeyboardEvent<HTMLTextAreaElement>){if(event.nativeEvent.isComposing||event.key==="Process")return;const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current<history.length-1){historyIndexRef.current+=1;setDraft(history[historyIndexRef.current])}else{historyIndexRef.current=null;setDraft(historyDraftRef.current)}return}if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();if(sendingRef.current||busy)return;event.currentTarget.form?.requestSubmit()}}
function selectSession(id:string){setActiveSessionId(id);setSessionMenuOpen(false);if(sessionStoreKey)writeSessionStore(sessionStoreKey,id)} 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?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 clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})}
@ -175,10 +184,10 @@ export function App(){
<main className="chat-panel"> <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} 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="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}<span className="message-body">{message.body}</span>{message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{t("working",{name:member.name})}</span></div>)}</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:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}<span className="message-body">{message.body}</span>{message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{t("working",{name:member.name})}</span></div>)}<div ref={messageEndRef} aria-hidden="true"/></div>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>} {error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>} {otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
<form className="composer" onSubmit={send}><button type="button" className="composer-plus" disabled title={t("attachmentsUnavailable")} aria-label={t("attachmentsUnavailable")}><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={e=>{if(e.nativeEvent.isComposing||e.key==="Process")return;if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(sendingRef.current||busy)return;e.currentTarget.form?.requestSubmit()}}} placeholder={activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form> <form className="composer" onSubmit={send}><button type="button" className="composer-plus" disabled title={t("attachmentsUnavailable")} aria-label={t("attachmentsUnavailable")}><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} placeholder={activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
</main> </main>
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>} {!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}

View File

@ -257,16 +257,18 @@ pub async fn worker_loop(state: AppState) {
.await .await
{ {
tracing::error!("run {run_id} failed: {error}"); tracing::error!("run {run_id} failed: {error}");
let retryable = retryable_run_error(&error);
let next_status: Option<String> = sqlx::query_scalar( let next_status: Option<String> = sqlx::query_scalar(
"UPDATE runs "UPDATE runs
SET status=CASE WHEN retry_count < max_retries THEN 'queued' ELSE 'failed' END, SET status=CASE WHEN $4 AND retry_count < max_retries THEN 'queued' ELSE 'failed' END,
error=$2, completed_at=CASE WHEN retry_count < max_retries THEN NULL ELSE now() END, error=$2, completed_at=CASE WHEN $4 AND retry_count < max_retries THEN NULL ELSE now() END,
lease_owner=NULL, lease_expires_at=NULL, updated_at=now() lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
WHERE id=$1 AND lease_owner=$3 AND status IN ('leased','running') RETURNING status", WHERE id=$1 AND lease_owner=$3 AND status IN ('leased','running') RETURNING status",
) )
.bind(&run_id) .bind(&run_id)
.bind(&error) .bind(&error)
.bind(&owner) .bind(&owner)
.bind(retryable)
.fetch_optional(state.pool()) .fetch_optional(state.pool())
.await .await
.ok() .ok()
@ -800,8 +802,21 @@ async fn complete_once(
match model { match model {
DynModel::Xai(model) => complete_with(model, pending, preamble, history, defs).await, DynModel::Xai(model) => complete_with(model, pending, preamble, history, defs).await,
DynModel::OpenAi(model) => complete_with(model, pending, preamble, history, defs).await, DynModel::OpenAi(model) => complete_with(model, pending, preamble, history, defs).await,
DynModel::OpenAiResponses(model) => {
complete_with(model, pending, preamble, history, defs).await
} }
} }
}
fn retryable_run_error(error: &str) -> bool {
let Some((_, suffix)) = error.split_once("status ") else {
return true;
};
let Some(code) = suffix.get(..3).and_then(|value| value.parse::<u16>().ok()) else {
return true;
};
!matches!(code, 400..=499 if !matches!(code, 408 | 409 | 425 | 429))
}
async fn complete_with<M>( async fn complete_with<M>(
model: &M, model: &M,
@ -1046,7 +1061,9 @@ async fn record_run_metrics(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{RunHalt, halt_from_status, history_window_start, screenshot_parts}; use super::{
RunHalt, halt_from_status, history_window_start, retryable_run_error, screenshot_parts,
};
use rig_core::completion::message::UserContent; use rig_core::completion::message::UserContent;
#[test] #[test]
@ -1077,4 +1094,18 @@ mod tests {
assert_eq!(halt_from_status(Some("running")), None); assert_eq!(halt_from_status(Some("running")), None);
assert_eq!(halt_from_status(None), None); assert_eq!(halt_from_status(None), None);
} }
#[test]
fn permanent_provider_errors_are_not_retried() {
assert!(!retryable_run_error(
"ProviderResponseError: status 403 Forbidden"
));
assert!(!retryable_run_error(
"ProviderResponseError: status 422 Unprocessable"
));
assert!(retryable_run_error(
"ProviderResponseError: status 429 Too Many Requests"
));
assert!(retryable_run_error("connection reset"));
}
} }

View File

@ -222,6 +222,16 @@ def evaluate(ws, expression, args=None):
raise RuntimeError(str(result["exceptionDetails"])) raise RuntimeError(str(result["exceptionDetails"]))
return val return val
def wait_for_visual_update(ws):
# CDP input and DOM clicks can complete before Chromium commits the next
# painted frame. The caller captures X11 immediately after this process
# exits, so wait for two animation frames to keep that screenshot aligned
# with the framebuffer streamed by VNC.
try:
evaluate(ws, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
except Exception:
pass
def snapshot(ws): def snapshot(ws):
val = evaluate(ws, SNAP_JS) or {} val = evaluate(ws, SNAP_JS) or {}
return { return {
@ -328,6 +338,7 @@ def main():
if not val.get("ok"): if not val.get("ok"):
fail(val.get("error") or "click failed") fail(val.get("error") or "click failed")
pointer(display, val.get("x") or 0, val.get("y") or 0) pointer(display, val.get("x") or 0, val.get("y") or 0)
wait_for_visual_update(ws)
print(json.dumps({"ok": True, "action": "click", "selector": sel, "restarted": restarted})) print(json.dumps({"ok": True, "action": "click", "selector": sel, "restarted": restarted}))
return return
if action == "type": if action == "type":
@ -339,11 +350,13 @@ def main():
pointer(display, val.get("x") or 0, val.get("y") or 0) pointer(display, val.get("x") or 0, val.get("y") or 0)
if text: if text:
ws.call("Input.insertText", {"text": text}) ws.call("Input.insertText", {"text": text})
wait_for_visual_update(ws)
print(json.dumps({"ok": True, "action": "type", "restarted": restarted})) print(json.dumps({"ok": True, "action": "type", "restarted": restarted}))
return return
if action == "press": if action == "press":
key = req.get("key") or "Return" key = req.get("key") or "Return"
press(ws, key) press(ws, key)
wait_for_visual_update(ws)
print(json.dumps({"ok": True, "action": "press", "key": key, "restarted": restarted})) print(json.dumps({"ok": True, "action": "press", "key": key, "restarted": restarted}))
return return
if action == "wait": if action == "wait":

View File

@ -113,6 +113,13 @@ fn rewrite_loopback_host(url: &str) -> String {
pub enum DynModel { pub enum DynModel {
Xai(xai::completion::CompletionModel), Xai(xai::completion::CompletionModel),
OpenAi(openai::completion::CompletionModel), OpenAi(openai::completion::CompletionModel),
OpenAiResponses(openai::responses_api::ResponsesCompletionModel),
}
fn uses_responses_api(provider: ModelProvider, model_id: &str) -> bool {
let id = model_id.to_ascii_lowercase();
provider == ModelProvider::OpencodeGo
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
} }
pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> { pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
@ -128,6 +135,16 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
} else { } else {
backend.api_key.as_str() backend.api_key.as_str()
}; };
if uses_responses_api(backend.provider, &backend.model_id) {
let client = openai::Client::builder()
.api_key(key.to_string())
.base_url(&backend.base_url)
.build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAiResponses(
client.completion_model(&backend.model_id),
))
} else {
let client = openai::CompletionsClient::builder() let client = openai::CompletionsClient::builder()
.api_key(key.to_string()) .api_key(key.to_string())
.base_url(&backend.base_url) .base_url(&backend.base_url)
@ -135,6 +152,7 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
.map_err(|error| ModelError::ProviderClient(error.to_string()))?; .map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id))) Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
} }
}
other => Err(ModelError::UnsupportedProvider { other => Err(ModelError::UnsupportedProvider {
provider: other.as_str().to_string(), provider: other.as_str().to_string(),
}), }),
@ -261,6 +279,20 @@ mod tests {
assert!(connect_model(&backend).is_ok()); assert!(connect_model(&backend).is_ok());
} }
#[test]
fn opencode_routes_responses_models_to_the_responses_api() {
assert!(uses_responses_api(
ModelProvider::OpencodeGo,
"gpt-5.6-luna"
));
assert!(uses_responses_api(ModelProvider::OpencodeGo, "grok-4.6"));
assert!(!uses_responses_api(ModelProvider::OpencodeGo, "glm-5.1"));
assert!(!uses_responses_api(
ModelProvider::OpenaiCompatible,
"gpt-5.6-luna"
));
}
#[test] #[test]
fn openai_compatible_allows_empty_key_and_custom_url() { fn openai_compatible_allows_empty_key_and_custom_url() {
let backend = resolve_backend(ResolveModelRequest { let backend = resolve_backend(ResolveModelRequest {