feat/test #2

Merged
daniel.w merged 7 commits from feat/test into main 2026-09-05 05:14:44 +00:00
14 changed files with 1001 additions and 50 deletions
Showing only changes of commit a0572099d0 - Show all commits

View File

@ -1,5 +1,5 @@
import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Users, X } from "./animated-icons";
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons";
import UseAnimations from "react-useanimations";
import loading from "react-useanimations/lib/loading";
import loading2 from "react-useanimations/lib/loading2";
@ -18,7 +18,7 @@ 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 type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
const SESSION_STORE="lazyboy.sessionByBot";
@ -36,6 +36,17 @@ function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat("zh-TW",{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat("zh-TW",{weekday:"long"}).format(date);return new Intl.DateTimeFormat("zh-TW",{month:"numeric",day:"numeric"}).format(date)}
const ATTACH_MAX=4;
const ATTACH_MAX_BYTES=10*1024*1024;
const ATTACH_ACCEPT=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.md,.csv,.json,.html,.htm,.xml,.docx,.xlsx,.pptx,image/*,text/*,application/pdf";
type PendingFile={id:string;file:File;preview:string|null};
function attachAllowed(file:File){const mime=(file.type||"").toLowerCase();if(mime.startsWith("image/")||mime.startsWith("text/")||mime==="application/pdf"||mime==="application/json"||mime==="application/xml")return true;return /\.(png|jpe?g|gif|webp|pdf|txt|md|csv|json|html?|xml|docx|xlsx|pptx)$/i.test(file.name)}
function readAsBase64(file:File){return new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>{const value=String(reader.result||"");const comma=value.indexOf(",");resolve(comma>=0?value.slice(comma+1):value)};reader.onerror=()=>reject(reader.error||new Error("read failed"));reader.readAsDataURL(file)})}
function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`}
function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()}
function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})}
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 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;
if(webCrypto&&typeof webCrypto.randomUUID==="function")return webCrypto.randomUUID();
@ -67,10 +78,11 @@ export function App(){
const [roomContext,setRoomContext]=useState<{room:Room;x:number;y:number}|null>(null);
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState<string|null>(null);
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState<string|null>(null);
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
const [looks,setLooks]=useState(readAvatarLooks);
const sendingRef=useRef(false); const refreshSeqRef=useRef(0);
const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef<HTMLInputElement>(null); const attachRef=useRef<HTMLInputElement>(null);
const [pendingFiles,setPendingFiles]=useState<PendingFile[]>([]);
const messageEndRef=useRef<HTMLDivElement|null>(null);
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
const roomsRef=useRef(rooms);
@ -91,6 +103,8 @@ export function App(){
const drafting=skills.find(skill=>skill.status==="drafting")||null;
const skillDraft=teaching||drafting?null:skills.find(skill=>skill.status==="draft")||null;
const savedSkills=skills.filter(skill=>skill.status==="saved");
const skillNeedle=skillQuery.trim().toLowerCase();
const listedSkills=skillNeedle?savedSkills.filter(skill=>skill.name.toLowerCase().includes(skillNeedle)||(skill.playbook.whenToUse||"").toLowerCase().includes(skillNeedle)||skill.goal.toLowerCase().includes(skillNeedle)):savedSkills;
const 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]);
@ -113,7 +127,8 @@ export function App(){
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(()=>{setMessages([]);loadSessions().catch(e=>setError(e.message))},[loadSessions]);
useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current=""},[activeSessionId]);
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.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)});
@ -141,7 +156,10 @@ export function App(){
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()}
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}}
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;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)}}
@ -205,10 +223,10 @@ export function App(){
<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>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}<span className="message-body">{message.body}</span>{message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{computer.busyStep&&member.id===computer.botId?t("workingStep",{name:member.name,step:computer.busyStep}):t("working",{name:member.name})}</span></div>)}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)}/>}<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: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 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>}{!hideBody&&<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>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{computer.busyStep&&member.id===computer.botId?t("workingStep",{name:member.name,step:computer.busyStep}):t("working",{name:member.name})}</span></div>)}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} 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>}
<form className="composer" onSubmit={send}><div className="plus-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className={`composer-plus ${plusOpen?"open":""}`} disabled={!activeSessionId} title={t("moreActions")} aria-label={t("moreActions")} aria-haspopup="menu" aria-expanded={plusOpen} onClick={()=>setPlusOpen(v=>!v)}><Plus/></button>{plusOpen&&<div className="plus-menu" role="menu"><button type="button" role="menuitem" disabled title={t("attachmentsUnavailable")}><Paperclip/>{t("attachFile")}</button><button type="button" role="menuitem" disabled={!active||Boolean(teaching)||Boolean(drafting)} title={active?t("teachTaskHint"):t("teachNeedsBot")} onClick={()=>{setPlusOpen(false);setTeachOpen(true)}}><i className="record-dot"/>{t("teachTask")}</button>{savedSkills.length>0&&<><hr/><small className="plus-menu-label">{t("taughtSkills")}</small>{savedSkills.map(skill=><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("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}</>}</div>}</div><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} placeholder={teaching?t("teachingComposerHint"):activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId||Boolean(teaching)}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
<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>}<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>
</main>
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}
@ -233,7 +251,7 @@ export function App(){
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><Ellipsis/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen">{frame}</div>{error&&<div className="overlay-error">{error}</div>}</div>}
{teachOpen&&active&&<TeachDialog bot={active} busy={busy} close={()=>setTeachOpen(false)} start={goal=>void startTeaching(goal)}/>}
{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)}/>}
{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=>{document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin));setClipboardOpen(false)}}/>}
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
@ -456,7 +474,9 @@ function TeachDialog({bot,busy,close,start}:{bot:Bot;busy:boolean;close:()=>void
</form></div>;
}
function stepText(step:unknown):string{if(typeof step==="string")return step;if(step&&typeof step==="object"&&"do" in step)return String((step as {do:unknown}).do||"");return ""}
function SkillDraftCard({skill,busy,onSave,onTest,onDiscard,onEdit}:{skill:TaughtSkill;busy:boolean;onSave:(name:string,playbook:Playbook)=>void;onTest:(name:string,playbook:Playbook)=>void;onDiscard:()=>void;onEdit:()=>void}){
function skillFilename(name:string){const slug=name.trim().replace(/[<>:"/\\|?*\u0000-\u001f]+/g,"").replace(/\s+/g,"-").replace(/\.+$/,"").slice(0,40)||"skill";return `${slug}.json`}
function downloadSkill(name:string,goal:string,playbook:Playbook){const payload={kind:"lazyboy.skill",version:1,name,goal,playbook:{...playbook,name}};const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download=skillFilename(name);document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)}
function SkillDraftCard({skill,busy,onSave,onTest,onDiscard,onEdit,onExport}:{skill:TaughtSkill;busy:boolean;onSave:(name:string,playbook:Playbook)=>void;onTest:(name:string,playbook:Playbook)=>void;onDiscard:()=>void;onEdit:()=>void;onExport:(name:string,playbook:Playbook)=>void}){
const [name,setName]=useState(skill.name||skill.playbook.name||"");
const [stepsText,setStepsText]=useState((skill.playbook.steps||[]).map(step=>stepText(step)).join("\n"));
const [expanded,setExpanded]=useState(false);
@ -471,7 +491,7 @@ function SkillDraftCard({skill,busy,onSave,onTest,onDiscard,onEdit}:{skill:Taugh
<label className="skill-steps">{t("skillSteps")}{expanded?<textarea rows={Math.min(12,Math.max(4,stepsText.split("\n").length+1))} value={stepsText} onChange={e=>setStepsText(e.target.value)}/>:<ol onClick={()=>setExpanded(true)}>{stepsText.split("\n").filter(Boolean).slice(0,6).map((line,index)=><li key={index}>{line}</li>)}{stepsText.split("\n").filter(Boolean).length>6&&<li className="more">{t("moreSteps",{count:stepsText.split("\n").filter(Boolean).length-6})}</li>}</ol>}{!expanded&&<button type="button" className="link" onClick={()=>setExpanded(true)}>{t("editSteps")}</button>}</label>
{skill.playbook.howToCheck&&<p className="skill-check">{t("skillCheck")}{skill.playbook.howToCheck}</p>}
{skill.error&&<p className="skill-error">{t("skillDistillFailed")}</p>}
<div className="skill-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={onDiscard}>{t("discard")}</button><button type="button" className="link" disabled={busy} onClick={onEdit}><Pencil/>{t("editSkillFull")}</button><span className="grow"/><button type="button" className="outline" disabled={busy||!name.trim()} onClick={()=>onTest(name.trim(),build())}>{t("testRun")}</button><button type="button" className="primary" disabled={busy||!name.trim()} onClick={()=>onSave(name.trim(),build())}>{t("saveSkill")}</button></div>
<div className="skill-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={onDiscard}>{t("discard")}</button><button type="button" className="link" disabled={busy} onClick={onEdit}><Pencil/>{t("editSkillFull")}</button><button type="button" className="link" disabled={busy||!name.trim()} onClick={()=>onExport(name.trim(),build())}><Download/>{t("exportSkill")}</button><span className="grow"/><button type="button" className="outline" disabled={busy||!name.trim()} onClick={()=>onTest(name.trim(),build())}>{t("testRun")}</button><button type="button" className="primary" disabled={busy||!name.trim()} onClick={()=>onSave(name.trim(),build())}>{t("saveSkill")}</button></div>
</section>;
}
const lines=(text:string)=>text.split("\n").map(line=>line.replace(/^\s*(?:\d+[.、)]|[-•*])\s*/,"").trim()).filter(Boolean);
@ -479,7 +499,7 @@ function stepsToText(steps:(PlaybookStep|string)[]|undefined){return (steps||[])
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 SkillEditDialog({skill,busy,close,save,test,remove}:{skill:TaughtSkill;busy:boolean;close:()=>void;save:(name:string,playbook:Playbook)=>void;test:(name:string,playbook:Playbook)=>void;remove:()=>void}){
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||"");
const [intent,setIntent]=useState(p.intent||"");
@ -505,7 +525,7 @@ function SkillEditDialog({skill,busy,close,save,test,remove}:{skill:TaughtSkill;
<label>{t("skillHowToCheck")}<textarea rows={rows(howToCheck,2,4)} value={howToCheck} onChange={e=>setHowToCheck(e.target.value)}/></label>
<label>{t("skillWhatToReturn")}<input value={whatToReturn} onChange={e=>setWhatToReturn(e.target.value)}/></label>
<label>{t("skillCautions")}<textarea rows={rows(cautions,2,5)} value={cautions} onChange={e=>setCautions(e.target.value)}/></label>
<div className="dialog-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={remove}>{t("deleteSkill")}</button><span className="grow"/><button type="button" className="outline" disabled={busy} onClick={close}>{t("cancel")}</button><button type="button" className="outline" disabled={busy||!valid} onClick={()=>test(name.trim(),build())}>{t("testRun")}</button><button className="primary" disabled={busy||!valid}>{t("saveSkill")}</button></div>
<div className="dialog-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={remove}>{t("deleteSkill")}</button><span className="grow"/><button type="button" className="outline" disabled={busy} onClick={close}>{t("cancel")}</button><button type="button" className="outline" disabled={!valid} onClick={()=>exportFile(name.trim(),build())}><Download/>{t("exportSkill")}</button><button type="button" className="outline" disabled={busy||!valid} onClick={()=>test(name.trim(),build())}>{t("testRun")}</button><button className="primary" disabled={busy||!valid}>{t("saveSkill")}</button></div>
</form></div>;
}
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>}
@ -613,6 +633,8 @@ function HelpDialog({close}:{close:()=>void}){
<section><h3>{t("helpComputerTitle")}</h3><p>{t("helpComputer")}</p></section>
<section><h3>{t("helpMemoryTitle")}</h3><p>{t("helpMemory")}</p></section>
<section><h3>{t("helpMcpTitle")}</h3><p>{t("helpMcp")}</p></section>
<section><h3>{t("helpSkillsTitle")}</h3><p>{t("helpSkills")}</p></section>
<section><h3>{t("helpAttachTitle")}</h3><p>{t("helpAttach")}</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>

View File

@ -60,3 +60,10 @@ export const Smartphone=animatedIcon(video,16);
export const Square=animatedIcon(playPause,14);
export const Users=animatedIcon(userPlus,17);
export const X=animatedIcon(plusToX,16,true);
export function Download({className,size=16}:IconProps){
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
export function Upload({className,size=16}:IconProps){
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}

View File

@ -17,8 +17,11 @@ export const zhTW = {
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
pausedNeedsUser: "{name} 需要你接手畫面(例如登入或驗證)。按「接手操作」處理,完成後再「釋放控制」。",
releaseAndContinue: "釋放控制並繼續", attachmentsUnavailable: "附件功能尚未開放", attachFile: "附加檔案",
releaseAndContinue: "釋放控制並繼續", attachFile: "附加檔案", attachFileHint: "圖片會直接給模型看;其他檔案放到電腦 inbox兩小時後刪除",
attachTooMany: "一次最多 4 個附件", attachTooLarge: "單檔不能超過 10 MB", attachType: "這個檔案類型不能附加",
attachRemove: "移除 {name}", attachedFile: "附件 {name}", attachDrop: "放到這裡附加",
teachTask: "教它一項任務", teachTaskHint: "你示範一次,它學成技能", teachNeedsBot: "先選擇一個機器人", taughtSkills: "已學會的技能",
searchSkills: "搜尋技能", noMatchingSkills: "沒有符合的技能",
teachDialogLead: "接下來畫面交給你操作,{name} 會在旁邊看:記錄你點了哪些控制項、輸入了什麼、去了哪些頁面,之後整理成一個「知道目的與流程」的技能,而不是死記座標。",
teachGoalLabel: "你要示範什麼?(一句話說目標)", teachGoalPlaceholder: "例如:到 STAR 訓練系統,把指定課程的影片看完並完成測驗",
teachTip1: "用平常的方式操作即可,不用刻意放慢;多餘的點擊會被忽略。", teachTip2: "會變動的值(搜尋字、名稱、日期)之後可以當作參數,做的時候先用實際例子。", teachTip3: "密碼欄位不會被記錄;遇到登入請先登好再開始示範。",
@ -32,6 +35,9 @@ export const zhTW = {
skillIntent: "意圖(這個技能要達成什麼)", skillWhenToUse: "使用時機", skillInputsLabel: "可變輸入(每行:名稱:說明|例:範例值)", skillInputsPlaceholder: "courseName課程名稱【TW】Workplace Bullying Prevention (2026)",
skillPreconditions: "前提(開始前要成立的條件)", skillStepsLabel: "步驟(每行一步,可用 → 接上預期看到的畫面)", skillStepsPlaceholder: "點「Next」翻頁 → 頁碼加一;若 Next 鎖住就等它啟用", skillHowToCheck: "怎麼確認完成", skillWhatToReturn: "做完要回報什麼", skillCautions: "注意事項(每行一項)",
deleteSkill: "刪除技能", deleteSkillConfirm: "確定刪除技能「{name}」?機器人之後就不會再認得它。", skillSaved: "已儲存",
exportSkill: "匯出", exportSkillHint: "下載成 JSON之後可以匯入到其他機器人",
importSkill: "匯入技能", importSkillHint: "從先前匯出的 JSON 檔加入技能",
skillImportInvalid: "這不是有效的技能檔。請選擇先前匯出的 JSON。",
collapseSidebar: "收合側欄", botComputer: "{name} 的電腦", dedicatedScreen: "獨立螢幕", enlarge: "放大",
userControlling: "你正在控制", aiReadOnly: "AI 操作中(唯讀)", readOnly: "唯讀", pasteClipboard: "貼上剪貼簿", copyDesktopClipboard: "複製桌面剪貼簿", moreActions: "更多操作",
unpin: "取消釘選", pin: "釘選", markUnread: "標示為未讀", enterGroupName: "輸入分組名稱", changeGroup: "變更分組", createOrMoveGroup: "建立/移入分組", removeFromGroup: "移出分組", unhide: "取消隱藏", hide: "隱藏", delete: "刪除",
@ -81,7 +87,10 @@ export const zhTW = {
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上對話時 Agent 可以使用。", helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出Shift+Enter 換行。正在回覆時送出鈕會變成停止。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上對話時 Agent 可以使用。",
helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。",
helpAttachTitle: "附件", helpAttach: " → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出Shift+Enter 換行。正在回覆時送出鈕會變成停止。",
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",

View File

@ -13,7 +13,21 @@
.thinking-dots{position:relative;display:flex;align-items:center;gap:4px;height:32px;padding:0 12px;border:1px solid var(--border);border-radius:14px;background:var(--surface);overflow:hidden}.thinking-dots:after{content:"";position:absolute;left:12px;bottom:4px;width:24px;height:2px;border-radius:999px;background:linear-gradient(90deg,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff73dc,#7c5cff);background-size:200% 100%;animation:small-magic-line 1.25s linear infinite}
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}.thinking-dots i:nth-child(2){animation-delay:.16s}.thinking-dots i:nth-child(3){animation-delay:.32s}
.composer{left:50%;right:auto;width:min(760px,calc(100% - 48px));min-height:62px;align-items:center;padding:9px 10px;transform:translateX(-50%)}
.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}
.composer-files{display:flex;flex-wrap:wrap;gap:8px;flex:1 0 100%;order:-1;padding:2px 8px 10px 46px}
.file-card{position:relative;display:flex;align-items:center;gap:8px;max-width:240px;height:48px;padding:2px 12px 2px 2px;border:1px solid #2a2a2a;border-radius:14px;background:#1a1a1a;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-meta{display:grid;min-width:0;padding-right:4px}
.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}
.file-card-remove{position:absolute;top:-7px;right:-7px;width:20px;height:20px;display:grid;place-items:center;border:1px solid #3a3a3a;border-radius:50%;background:#141414;color:#9a9a9a;cursor:pointer}
.file-card-remove:hover{color:var(--ink);background:#252525}
.file-card-remove svg{width:11px;height:11px}
.message.with-files{align-items:flex-end;flex-direction:column;gap:8px}
.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{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 svg{width:14px;height:14px;fill:currentColor}
.spinner{width:17px;height:17px;animation:spin .8s linear infinite}.spinner.large{width:38px;height:38px;color:var(--accent)}.primary .spinner{margin-right:7px}.primary{display:inline-flex;align-items:center;justify-content:center}
@ -268,13 +282,20 @@
/* Teach-by-demonstration: composer menu, live banner, draft card */
.plus-menu-wrap{position:relative;align-self:center}
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}
.plus-menu{position:absolute;z-index:40;bottom:50px;left:0;display:grid;width:240px;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:#18181b;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}
.plus-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}
.plus-menu button:disabled{opacity:.4;cursor:not-allowed}
.plus-menu svg{width:16px;height:16px;flex:0 0 16px}
.plus-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}
.plus-menu-label{padding:4px 10px 2px;color:var(--faint);font-size:11px}
.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:focus{border-color:#4b4b50}
.plus-menu-skill-list{overflow-y:auto;max-height:min(228px,calc(100dvh - 280px));padding-bottom:2px}
.plus-menu-skill-list::-webkit-scrollbar{width:8px}
.plus-menu-skill-list::-webkit-scrollbar-thumb{border-radius:8px;background:#2f2f33}
.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.live{animation:pulse 1.2s infinite;margin-right:8px;vertical-align:-1px}
.teach-dialog h2{display:flex;align-items:center;gap:10px}
@ -313,6 +334,7 @@
.plus-menu-skill .skill-edit{flex:0 0 30px;width:30px;height:30px;padding:0;justify-content:center;color:var(--muted)}
.plus-menu-skill .skill-edit:hover{color:var(--ink)}
.plus-menu-skill .skill-edit svg{width:14px;height:14px;flex-basis:14px}
.skill-import-input{position:absolute;left:0;bottom:0;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0;opacity:0}
/* Full skill editor */
.dialog.skill-edit{width:min(600px,calc(100vw - 28px));max-height:calc(100dvh - 28px);overflow:auto}
.dialog.skill-edit h2{display:flex;align-items:center;gap:9px}
@ -320,5 +342,6 @@
.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:focus,.dialog.skill-edit textarea:focus{border-color:#4b4b50}
.dialog.skill-edit .dialog-actions{align-items:center;gap:8px}
.dialog.skill-edit .dialog-actions{align-items:center;gap:8px;flex-wrap:wrap}
.dialog.skill-edit .dialog-actions .grow{flex:1}
.dialog.skill-edit .dialog-actions .outline svg{fill:none;stroke:currentColor;width:14px;height:14px}

View File

@ -6,6 +6,7 @@ export type AvatarShape = BlobatarShape|"blob"|"squircle"|"diamond"|"drop"|"orga
export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean }
export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number }
export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null }
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }

View File

@ -0,0 +1,481 @@
//! Chat attachments: bytes go to the model once, then we drop them.
//!
//! The chat row only stores name/mime/size. If the bot's computer needs a copy
//! (open a PDF, upload a spreadsheet), we write it under `inbox/` on the bind-
//! mounted home and delete anything older than [`INBOX_TTL`].
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use base64::Engine;
use lazyboy_contracts::SessionAttachment;
use lazyboy_control::resolve_bot_workspace_path;
use rig_core::completion::message::{ImageDetail, ImageMediaType, UserContent};
use serde::Serialize;
use serde_json::{Value, json};
use tokio::fs;
use crate::computer;
use crate::db::{Actor, parse_mode};
use crate::state::AppState;
pub const INBOX_TTL: Duration = Duration::from_secs(2 * 60 * 60);
pub const MAX_COUNT: usize = 4;
pub const MAX_BYTES: usize = 10 * 1024 * 1024;
pub const MAX_TOTAL_BYTES: usize = 20 * 1024 * 1024;
const TEXT_INLINE_CHARS: usize = 80_000;
const NAME_MAX: usize = 80;
pub type IncomingAttachment = SessionAttachment;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredAttachment {
pub kind: &'static str,
pub name: String,
pub mime_type: String,
pub size: usize,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct DecodedAttachment {
pub name: String,
pub mime_type: String,
pub bytes: Vec<u8>,
}
pub fn decode_incoming(items: &[IncomingAttachment]) -> Result<Vec<DecodedAttachment>, String> {
if items.len() > MAX_COUNT {
return Err(format!("最多 {MAX_COUNT} 個附件"));
}
let mut out = Vec::with_capacity(items.len());
let mut total = 0usize;
for item in items {
let name = safe_name(&item.name)?;
let mime = normalize_mime(&item.mime_type, &name);
if !allowed_mime(&mime) {
return Err(format!("不支援的檔案類型:{name}"));
}
let bytes = decode_base64(&item.content)?;
if bytes.is_empty() {
return Err(format!("空檔案:{name}"));
}
if bytes.len() > MAX_BYTES {
return Err(format!("{name} 超過 10 MB"));
}
total = total.saturating_add(bytes.len());
if total > MAX_TOTAL_BYTES {
return Err("附件合計超過 20 MB".into());
}
out.push(DecodedAttachment {
name,
mime_type: mime,
bytes,
});
}
Ok(out)
}
pub fn stored_blocks(files: &[DecodedAttachment]) -> Vec<Value> {
files
.iter()
.map(|file| {
json!({
"kind": if is_image(&file.mime_type) { "image" } else { "file" },
"name": file.name,
"mimeType": file.mime_type,
"size": file.bytes.len(),
"path": format!("inbox/{}", file.name),
})
})
.collect()
}
pub fn caption_for_title(text: &str, files: &[DecodedAttachment]) -> String {
let trimmed = text.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
match files.first() {
Some(file) => format!("附件 {}", file.name),
None => String::new(),
}
}
/// Write copies into each bot's `inbox/` so the desktop can open them.
pub async fn stage_for_bots(
state: &AppState,
actor: &Actor,
bot_ids: &[String],
files: &[DecodedAttachment],
) -> Result<(), String> {
if files.is_empty() {
return Ok(());
}
for bot_id in bot_ids {
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await? else {
continue;
};
fs::create_dir_all(&dir)
.await
.map_err(|error| error.to_string())?;
for file in files {
let path = unique_path(&dir, &file.name).await;
fs::write(&path, &file.bytes)
.await
.map_err(|error| error.to_string())?;
}
}
Ok(())
}
pub async fn llm_parts(
state: &AppState,
actor: &Actor,
bot_id: &str,
blocks: &[Value],
vision: bool,
) -> Vec<UserContent> {
let files = load_from_blocks(state, actor, bot_id, blocks).await;
if files.is_empty() && blocks.is_empty() {
return Vec::new();
}
let mut parts = Vec::new();
let mut notes = Vec::new();
if !blocks.is_empty() {
notes.push(format!(
"User attached {} file(s). Chat history does not keep the bytes. Copies live in inbox/ on this computer and expire after 2 hours.",
blocks.len()
));
}
for (stored, bytes) in files {
notes.push(format!(
"- {} ({}, {} bytes) at inbox/{}",
stored.name, stored.mime_type, stored.size, stored.name
));
if is_image(&stored.mime_type) {
if vision {
if let Some(bytes) = bytes {
parts.push(UserContent::image_base64(
base64::engine::general_purpose::STANDARD.encode(bytes),
Some(image_media(&stored.mime_type)),
Some(ImageDetail::High),
));
} else {
notes.push(" (image missing from inbox; it may have expired)".into());
}
}
} else if is_text_mime(&stored.mime_type) {
if let Some(bytes) = bytes {
if let Some(text) = utf8_preview(&bytes) {
parts.push(UserContent::text(format!(
"Contents of {}:\n```\n{text}\n```",
stored.name
)));
}
}
} else {
notes.push(
" Open this with open_path or the desktop if you need the original file.".into(),
);
}
}
if !notes.is_empty() {
parts.insert(0, UserContent::text(notes.join("\n")));
}
parts
}
pub async fn sweep_all_inboxes(data_dir: &str) {
let homes = PathBuf::from(data_dir).join("homes");
let Ok(mut spaces) = fs::read_dir(&homes).await else {
return;
};
while let Ok(Some(entry)) = spaces.next_entry().await {
let path = entry.path();
if !path.is_dir() {
continue;
}
sweep_dir(&path.join("inbox")).await;
let bots = path.join("bots");
let Ok(mut bots_dir) = fs::read_dir(&bots).await else {
continue;
};
while let Ok(Some(bot)) = bots_dir.next_entry().await {
sweep_dir(&bot.path().join("inbox")).await;
}
}
}
async fn inbox_dir_for_bot(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<Option<PathBuf>, String> {
let Some(bot) = state
.db
.get_bot(actor, bot_id)
.await
.map_err(|error| error.to_string())?
else {
return Ok(None);
};
let Some(computer_id) = bot.computer_id.as_deref() else {
return Ok(None);
};
let Some(computer) = state
.db
.get_computer(computer_id)
.await
.map_err(|error| error.to_string())?
else {
return Ok(None);
};
let home = computer::home_path(&state.data_dir, &computer.home_key);
let relative = resolve_bot_workspace_path(parse_mode(&computer.scope), bot_id, "inbox")
.unwrap_or_else(|_| "inbox".into());
Ok(Some(home.join(relative)))
}
async fn load_from_blocks(
state: &AppState,
actor: &Actor,
bot_id: &str,
blocks: &[Value],
) -> Vec<(StoredAttachment, Option<Vec<u8>>)> {
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await.ok().flatten() else {
return blocks
.iter()
.filter_map(parse_stored)
.map(|file| (file, None))
.collect();
};
let mut out = Vec::new();
for block in blocks {
let Some(file) = parse_stored(block) else {
continue;
};
let path = dir.join(&file.name);
let bytes = fs::read(&path).await.ok();
out.push((file, bytes));
}
out
}
fn parse_stored(value: &Value) -> Option<StoredAttachment> {
let kind = value.get("kind").and_then(Value::as_str)?;
if kind != "file" && kind != "image" {
return None;
}
let name = value.get("name").and_then(Value::as_str)?;
Some(StoredAttachment {
kind: if kind == "image" { "image" } else { "file" },
name: name.to_string(),
mime_type: value
.get("mimeType")
.and_then(Value::as_str)
.unwrap_or("application/octet-stream")
.to_string(),
size: value.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
path: value
.get("path")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
}
async fn sweep_dir(dir: &Path) {
let Ok(mut entries) = fs::read_dir(dir).await else {
return;
};
let cutoff = SystemTime::now() - INBOX_TTL;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let Ok(meta) = fs::metadata(&path).await else {
continue;
};
if !meta.is_file() {
continue;
}
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
if modified < cutoff {
let _ = fs::remove_file(&path).await;
}
}
}
async fn unique_path(dir: &Path, name: &str) -> PathBuf {
let candidate = dir.join(name);
if fs::metadata(&candidate).await.is_err() {
return candidate;
}
let (stem, ext) = split_ext(name);
for n in 2..1000 {
let next = dir.join(format!("{stem}-{n}{ext}"));
if fs::metadata(&next).await.is_err() {
return next;
}
}
dir.join(format!("{stem}-{}.bin", uuid::Uuid::new_v4()))
}
fn split_ext(name: &str) -> (String, String) {
match name.rfind('.') {
Some(index) if index > 0 => (name[..index].to_string(), name[index..].to_string()),
_ => (name.to_string(), String::new()),
}
}
pub fn safe_name(name: &str) -> Result<String, String> {
let file = Path::new(name)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("")
.trim();
let mut out = String::new();
for ch in file.chars() {
if ch.is_control() || "/\\:".contains(ch) {
continue;
}
out.push(ch);
if out.chars().count() >= NAME_MAX {
break;
}
}
let out = out.trim_matches('.').trim().to_string();
if out.is_empty() || out == "." || out == ".." {
return Err("檔名無效".into());
}
Ok(out)
}
fn decode_base64(value: &str) -> Result<Vec<u8>, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("附件內容是空的".into());
}
base64::engine::general_purpose::STANDARD
.decode(trimmed)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed.replace('\n', "")))
.map_err(|_| "附件不是有效的 base64".into())
}
fn normalize_mime(mime: &str, name: &str) -> String {
let mime = mime.trim().to_ascii_lowercase();
if !mime.is_empty() && mime != "application/octet-stream" {
return mime.split(';').next().unwrap_or(&mime).trim().to_string();
}
match Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"pdf" => "application/pdf",
"txt" | "log" => "text/plain",
"md" => "text/markdown",
"csv" => "text/csv",
"json" => "application/json",
"html" | "htm" => "text/html",
"xml" => "application/xml",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
_ => "application/octet-stream",
}
.into()
}
fn allowed_mime(mime: &str) -> bool {
is_image(mime)
|| is_text_mime(mime)
|| matches!(
mime,
"application/pdf"
| "application/xml"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
)
}
fn is_image(mime: &str) -> bool {
matches!(
mime,
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
)
}
fn is_text_mime(mime: &str) -> bool {
mime.starts_with("text/") || matches!(mime, "application/json" | "application/xml")
}
fn image_media(mime: &str) -> ImageMediaType {
if mime == "image/jpeg" {
ImageMediaType::JPEG
} else {
ImageMediaType::PNG
}
}
fn utf8_preview(bytes: &[u8]) -> Option<String> {
if bytes.contains(&0) {
return None;
}
let text = std::str::from_utf8(bytes).ok()?;
let preview: String = text.chars().take(TEXT_INLINE_CHARS).collect();
if text.chars().count() > TEXT_INLINE_CHARS {
Some(format!("{preview}\n…(truncated)"))
} else {
Some(preview)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_path_in_name() {
assert_eq!(safe_name("../../etc/passwd").unwrap(), "passwd");
assert!(safe_name("...").is_err());
}
#[test]
fn guesses_png_mime() {
assert_eq!(normalize_mime("", "shot.PNG"), "image/png");
assert!(allowed_mime("image/png"));
assert!(!allowed_mime("application/x-msdownload"));
}
#[test]
fn stored_blocks_drop_bytes() {
let files = [DecodedAttachment {
name: "a.png".into(),
mime_type: "image/png".into(),
bytes: vec![1, 2, 3],
}];
let blocks = stored_blocks(&files);
assert_eq!(blocks[0]["kind"], "image");
assert_eq!(blocks[0]["path"], "inbox/a.png");
assert!(blocks[0].get("content").is_none());
}
#[test]
fn decode_limits_count() {
let item = IncomingAttachment {
name: "a.txt".into(),
mime_type: "text/plain".into(),
content: base64::engine::general_purpose::STANDARD.encode("hi"),
};
let many = vec![item; 5];
assert!(decode_incoming(&many).is_err());
}
}

View File

@ -932,6 +932,7 @@ pub fn user_has_screen_control(
pub async fn idle_loop(state: AppState) {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
crate::attachments::sweep_all_inboxes(&state.data_dir).await;
let cutoff = Utc::now() - TimeDelta::minutes(10);
let rows = sqlx::query_as::<_, ComputerRow>(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
@ -1048,9 +1049,7 @@ pub async fn current_status(
.fetch_all(state.pool())
.await
.unwrap_or_default();
let waiting = active
.iter()
.find(|run| run.status == "waiting_takeover");
let waiting = active.iter().find(|run| run.status == "waiting_takeover");
let busy = active.iter().find(|run| {
parse_run_status(&run.status).is_some_and(|status| {
status.is_active() && status != lazyboy_contracts::RunStatus::WaitingTakeover
@ -1077,7 +1076,9 @@ pub async fn current_status(
}
status.queued_runs = active
.iter()
.filter(|run| run.status == "queued" && Some(run.id.as_str()) != busy.map(|b| b.id.as_str()))
.filter(|run| {
run.status == "queued" && Some(run.id.as_str()) != busy.map(|b| b.id.as_str())
})
.count() as u32;
Ok(status)
}

View File

@ -1,3 +1,4 @@
mod attachments;
mod auth;
mod computer;
mod db;
@ -18,6 +19,7 @@ use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use state::AppState;
use tower_http::services::ServeDir;
use tracing_subscriber::EnvFilter;
@ -72,7 +74,8 @@ async fn main() {
)
.merge(auth::public_router(state.clone()))
.merge(routes::router(state))
.fallback_service(ServeDir::new(web_dir));
.fallback_service(ServeDir::new(web_dir))
.layer(DefaultBodyLimit::max(24 * 1024 * 1024));
tracing::info!("api listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");

View File

@ -478,6 +478,8 @@ struct SendBody {
client_nonce: Option<String>,
#[serde(default)]
blocks: Vec<Value>,
#[serde(default)]
attachments: Vec<lazyboy_contracts::SessionAttachment>,
}
async fn send_message(
@ -504,6 +506,7 @@ async fn send_message(
&body.text,
body.client_nonce.as_deref(),
&body.blocks,
&body.attachments,
)
.await
.map_err(|error| {

View File

@ -2,7 +2,7 @@ use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use lazyboy_contracts::ModelProvider;
use lazyboy_contracts::{ModelProvider, SessionAttachment};
use lazyboy_harness::{
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
};
@ -54,7 +54,7 @@ computer_act examples (native windows only):
- {\"kind\":\"focus\",\"title\":\"Open File\"}
- wait: {\"seconds\":30,\"reason\":\"video playing\"}
On a Team Computer, relative files live in your bot folder; use shared/ for shared work. Finish the user's task.";
On a Team Computer, relative files live in your bot folder; use shared/ for shared work. User-attached files appear in inbox/ for two hours only chat history does not keep the bytes. Open them with open_path when you need the original file. Finish the user's task.";
pub async fn send(
state: &AppState,
@ -64,6 +64,7 @@ pub async fn send(
text: &str,
client_nonce: Option<&str>,
blocks: &[Value],
attachments: &[SessionAttachment],
) -> Result<Value, String> {
if crate::skills::recording_skill(state.pool(), bot_id)
.await
@ -71,6 +72,13 @@ pub async fn send(
{
return Err("示範進行中:先按「完成示範」或「取消」,再送訊息。".into());
}
let decoded = crate::attachments::decode_incoming(attachments)?;
if text.trim().is_empty() && decoded.is_empty() {
return Err("empty message".into());
}
let stored_body = crate::attachments::caption_for_title(text, &decoded);
let mut stored_blocks = blocks.to_vec();
stored_blocks.extend(crate::attachments::stored_blocks(&decoded));
let mut tx = state
.pool()
.begin()
@ -129,8 +137,8 @@ pub async fn send(
.bind(&message_id)
.bind(thread_id)
.bind(seq)
.bind(text)
.bind(json!(blocks))
.bind(&stored_body)
.bind(json!(stored_blocks))
.bind(&run_id)
.bind(client_nonce)
.execute(&mut *tx)
@ -139,7 +147,7 @@ pub async fn send(
if crate::sessions::is_default_session_title(&current_title) {
sqlx::query("UPDATE threads SET title=$2 WHERE id=$1")
.bind(thread_id)
.bind(crate::sessions::title_from_first_message(text))
.bind(crate::sessions::title_from_first_message(&stored_body))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
@ -171,7 +179,7 @@ pub async fn send(
.bind(member_id)
.bind(&thread_id)
.bind(&actor.user_id)
.bind(text)
.bind(&stored_body)
.bind(json!({"messageSeq":seq}))
.execute(&mut *tx)
.await
@ -190,7 +198,7 @@ pub async fn send(
.bind(Uuid::new_v4().to_string())
.bind(thread_id)
.bind(event_seq)
.bind(json!({"id":message_id,"seq":seq,"role":"user","body":text,"runId":run_id}))
.bind(json!({"id":message_id,"seq":seq,"role":"user","body":stored_body,"runId":run_id}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
@ -211,6 +219,11 @@ pub async fn send(
.map_err(|error| error.to_string())?;
}
tx.commit().await.map_err(|error| error.to_string())?;
if let Err(error) =
crate::attachments::stage_for_bots(state, actor, &member_ids, &decoded).await
{
tracing::warn!("stage attachments for {message_id}: {error}");
}
Ok(json!({
"messageId": message_id,
"runId": run_id,
@ -498,12 +511,28 @@ async fn execute_run(
} else {
vec![UserContent::text(prompt)]
};
if !resume_after_takeover {
let blocks: Value = sqlx::query_scalar(
"SELECT blocks FROM messages WHERE thread_id=$1 AND seq=$2 AND role='user'",
)
.bind(thread_id)
.bind(current_seq)
.fetch_optional(state.pool())
.await
.ok()
.flatten()
.unwrap_or(json!([]));
let blocks = blocks.as_array().cloned().unwrap_or_default();
first.extend(crate::attachments::llm_parts(state, actor, bot_id, &blocks, vision).await);
}
let mut skill_check: Option<String> = None;
if !resume_after_takeover {
// The user named a taught skill: hand the model the full playbook up
// front so it does not have to guess or call use_skill first.
if let Some(skill) = crate::skills::skill_for_prompt(state.pool(), bot_id, prompt).await {
first.push(UserContent::text(crate::skills::format_playbook_for_run(&skill)));
first.push(UserContent::text(crate::skills::format_playbook_for_run(
&skill,
)));
skill_check = Some(crate::skills::skill_check_hint(&skill));
}
}
@ -677,7 +706,12 @@ async fn execute_run(
match nudge {
Some(text) if nudges < 6 && turns + 2 < max_turns => {
nudges += 1;
tracing::info!(run_id, turn = turns, parroted, "nudging model back to tools");
tracing::info!(
run_id,
turn = turns,
parroted,
"nudging model back to tools"
);
earlier_replies.push(final_text.trim().to_string());
final_text.clear();
let mut content = vec![UserContent::text(text)];
@ -718,7 +752,12 @@ async fn execute_run(
let name = call.function.name.clone();
used_gui |= matches!(
name.as_str(),
"computer_observe" | "computer_act" | "open_path" | "launch_app" | "browser" | "wait"
"computer_observe"
| "computer_act"
| "open_path"
| "launch_app"
| "browser"
| "wait"
);
let step = describe_step(&name, &call.function.arguments);
set_run_step(state, run_id, &step).await;
@ -1033,7 +1072,10 @@ fn drop_history_screenshots(history: &mut [Message], pending: &Message) {
/// Cancel every unfinished run of a bot and free the screen/execution leases
/// it held, so the desktop is available to a human immediately.
pub(crate) async fn cancel_active_runs(state: &AppState, bot_id: &str) -> Result<Vec<String>, String> {
pub(crate) async fn cancel_active_runs(
state: &AppState,
bot_id: &str,
) -> Result<Vec<String>, String> {
let run_ids: Vec<String> = sqlx::query_scalar(
"UPDATE runs SET status = 'cancelled', completed_at = now(), updated_at = now()
WHERE bot_id = $1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
@ -1245,7 +1287,8 @@ fn describe_step(name: &str, args: &Value) -> String {
let kind = field("kind").or(field("type")).unwrap_or("?");
let target = if let Some(text) = field("text").or(field("keys")) {
short(Some(text), 24)
} else if let Some(id) = lazyboy_control::element_id(action.get("element")) {
} else if let Some(id) = lazyboy_control::element_id(action.get("element"))
{
format!("#{id}")
} else if let (Some(x), Some(y)) = (
action.get("x").and_then(Value::as_i64),
@ -1262,16 +1305,28 @@ fn describe_step(name: &str, args: &Value) -> String {
})
.unwrap_or_default(),
"browser" => {
let target = get("url").or(get("text")).or(get("selector")).map(|s| short(Some(s), 40));
let target = target.or_else(|| lazyboy_control::element_id(args.get("element")).map(|id| format!("#{id}")));
format!("{} {}", get("action").unwrap_or("snapshot"), target.unwrap_or_default())
let target = get("url")
.or(get("text"))
.or(get("selector"))
.map(|s| short(Some(s), 40));
let target = target.or_else(|| {
lazyboy_control::element_id(args.get("element")).map(|id| format!("#{id}"))
});
format!(
"{} {}",
get("action").unwrap_or("snapshot"),
target.unwrap_or_default()
)
.trim()
.to_string()
}
"shell" => short(get("command").or(get("cmd")), 60),
"wait" => format!(
"{}s {}",
args.get("seconds").and_then(Value::as_f64).unwrap_or(0.0).round(),
args.get("seconds")
.and_then(Value::as_f64)
.unwrap_or(0.0)
.round(),
short(get("reason"), 30)
)
.trim()
@ -1349,7 +1404,10 @@ mod tests {
#[test]
fn step_labels_summarize_tool_arguments() {
assert_eq!(describe_step("computer_observe", &json!({})), "computer_observe: 看畫面");
assert_eq!(
describe_step("computer_observe", &json!({})),
"computer_observe: 看畫面"
);
assert_eq!(
describe_step(
"computer_act",
@ -1365,7 +1423,10 @@ mod tests {
describe_step("shell", &json!({"command":"ls\n-la"})),
"shell: ls -la"
);
assert_eq!(describe_step("mcp_search", &json!({"query":"x"})), "mcp_search");
assert_eq!(
describe_step("mcp_search", &json!({"query":"x"})),
"mcp_search"
);
}
#[test]
@ -1419,7 +1480,11 @@ mod tests {
let Message::User { content } = &history[2] else {
panic!("expected user message");
};
assert!(content.iter().any(|part| matches!(part, UserContent::Image(_))));
assert!(
content
.iter()
.any(|part| matches!(part, UserContent::Image(_)))
);
}
#[test]

View File

@ -324,7 +324,7 @@ async fn send_message(
Json(input): Json<SendSessionMessageInput>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let actor = actor(&state).await?;
if input.text.trim().is_empty() {
if input.text.trim().is_empty() && input.attachments.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"empty message"})),
@ -346,6 +346,7 @@ async fn send_message(
input.text.trim(),
input.client_nonce.as_deref(),
&input.blocks,
&input.attachments,
)
.await
.map_err(|message| (StatusCode::BAD_REQUEST, Json(json!({"message":message}))))?;

View File

@ -13,7 +13,7 @@
use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use base64::Engine;
@ -48,7 +48,9 @@ pub fn router() -> Router<AppState> {
.route("/api/bots/{id}/skills/start", post(start_skill))
.route("/api/bots/{id}/skills/stop", post(stop_skill))
.route("/api/bots/{id}/skills/cancel", post(cancel_skill))
.route("/api/bots/{id}/skills/import", post(import_skill))
.route("/api/skills/{id}", patch(update_skill).delete(delete_skill))
.route("/api/skills/{id}/export", get(export_skill))
.route("/api/skills/{id}/test", post(test_skill))
}
@ -474,12 +476,278 @@ async fn test_skill(
.map_err(|error| internal(error.to_string()))?
.ok_or_else(not_found)?;
let prompt = format!("試跑技能「{name}」:照剛學到的流程做一遍,做完回報結果。");
let result = crate::runs::send(&state, &actor, &row.bot_id, &thread_id, &prompt, None, &[])
let result = crate::runs::send(
&state,
&actor,
&row.bot_id,
&thread_id,
&prompt,
None,
&[],
&[],
)
.await
.map_err(bad_request)?;
Ok(Json(result))
}
/// Portable JSON a human can download after a demo and load onto another bot.
pub const SKILL_FILE_KIND: &str = "lazyboy.skill";
pub const SKILL_FILE_VERSION: u32 = 1;
const SKILL_NAME_MAX: usize = 40;
pub fn skill_file(name: &str, goal: &str, playbook: &Value) -> Value {
json!({
"kind": SKILL_FILE_KIND,
"version": SKILL_FILE_VERSION,
"name": name,
"goal": goal,
"playbook": playbook,
})
}
fn clip_name(name: &str) -> String {
name.chars().take(SKILL_NAME_MAX).collect()
}
pub fn unique_skill_name(existing: &[String], wanted: &str) -> String {
let wanted = clip_name(wanted.trim());
let clash = |candidate: &str| {
existing
.iter()
.any(|have| have.eq_ignore_ascii_case(candidate))
};
if !clash(&wanted) {
return wanted;
}
for n in 2..1000 {
let suffix = format!(" ({n})");
let budget = SKILL_NAME_MAX.saturating_sub(suffix.chars().count());
let base: String = wanted.chars().take(budget).collect();
let candidate = format!("{base}{suffix}");
if !clash(&candidate) {
return candidate;
}
}
wanted
}
fn sanitize_steps(value: Option<&Value>) -> Result<Vec<Value>, String> {
let steps = value
.and_then(Value::as_array)
.ok_or_else(|| "steps required".to_string())?;
let steps: Vec<Value> = steps
.iter()
.filter_map(|step| match step {
Value::String(text) => {
let text = text.trim();
if text.is_empty() {
None
} else {
Some(json!({ "do": text, "expect": "", "note": "" }))
}
}
Value::Object(obj) => {
let action = obj
.get("do")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())?;
Some(json!({
"do": action,
"expect": obj.get("expect").and_then(Value::as_str).unwrap_or(""),
"note": obj.get("note").and_then(Value::as_str).unwrap_or(""),
}))
}
_ => None,
})
.collect();
if steps.is_empty() {
return Err("steps required".into());
}
Ok(steps)
}
fn sanitize_inputs(value: Option<&Value>) -> Vec<Value> {
value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let name = item
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())?;
Some(json!({
"name": name,
"description": item.get("description").and_then(Value::as_str).unwrap_or(""),
"example": item.get("example").and_then(Value::as_str).unwrap_or(""),
}))
})
.collect()
})
.unwrap_or_default()
}
fn sanitize_playbook(playbook: &Value, name: &str) -> Result<Value, String> {
let text = |key: &str| {
playbook
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("")
};
Ok(json!({
"name": name,
"whenToUse": text("whenToUse"),
"intent": text("intent"),
"inputs": sanitize_inputs(playbook.get("inputs")),
"preconditions": strings(playbook.get("preconditions")),
"steps": sanitize_steps(playbook.get("steps"))?,
"howToCheck": text("howToCheck"),
"whatToReturn": text("whatToReturn"),
"cautions": strings(playbook.get("cautions")),
}))
}
/// Accepts the envelope we export, or a bare playbook object with `name` + `steps`.
pub fn parse_skill_file(value: &Value) -> Result<(String, String, Value), String> {
if !value.is_object() {
return Err("skill file must be a JSON object".into());
}
if let Some(kind) = value.get("kind").and_then(Value::as_str)
&& kind != SKILL_FILE_KIND
{
return Err(format!("unsupported skill kind: {kind}"));
}
let playbook = match value.get("playbook") {
Some(inner) if inner.is_object() => inner,
_ => value,
};
let name = value
.get("name")
.or_else(|| playbook.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.ok_or_else(|| "name required".to_string())?;
let name = clip_name(name);
let goal = value
.get("goal")
.and_then(Value::as_str)
.map(str::trim)
.filter(|goal| !goal.is_empty())
.map(str::to_string)
.or_else(|| {
playbook
.get("intent")
.and_then(Value::as_str)
.map(str::trim)
.filter(|intent| !intent.is_empty())
.map(str::to_string)
})
.unwrap_or_else(|| name.clone());
let playbook = sanitize_playbook(playbook, &name)?;
Ok((name, goal, playbook))
}
fn export_filename_ascii(name: &str) -> String {
let slug: String = name
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(40)
.collect();
if slug.is_empty() {
"skill.json".into()
} else {
format!("{slug}.json")
}
}
async fn export_skill(
State(state): State<AppState>,
Path(skill_id): Path<String>,
) -> Result<(HeaderMap, Json<Value>), ApiError> {
let actor = actor(&state).await?;
let row = load_skill(&state, &actor, &skill_id).await?;
if !matches!(row.status.as_str(), "draft" | "saved") {
return Err(conflict("skill is not ready yet"));
}
let name = if row.name.trim().is_empty() {
row.playbook
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(&row.goal)
.to_string()
} else {
row.name.clone()
};
let payload = skill_file(&name, &row.goal, &row.playbook);
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(&format!(
"attachment; filename=\"{}\"",
export_filename_ascii(&name)
)) {
headers.insert(header::CONTENT_DISPOSITION, value);
}
Ok((headers, Json(payload)))
}
async fn import_skill(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(body): Json<Value>,
) -> Result<Json<Skill>, ApiError> {
let actor = actor(&state).await?;
state
.db
.get_bot(&actor, &bot_id)
.await
.map_err(|error| internal(error.to_string()))?
.ok_or_else(not_found)?;
let (name, goal, mut playbook) = parse_skill_file(&body).map_err(bad_request)?;
let existing = sqlx::query_scalar::<_, String>(
"SELECT name FROM taught_skills
WHERE bot_id = $1 AND space_id = $2 AND user_id = $3
AND status IN ('saved','draft','drafting','recording')",
)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
let name = unique_skill_name(&existing, &name);
if let Some(object) = playbook.as_object_mut() {
object.insert("name".into(), json!(name));
}
let thread_id = crate::sessions::default_session_for_bot(&state, &actor, &bot_id)
.await
.map_err(|error| internal(error.to_string()))?;
let skill_id = Uuid::new_v4().to_string();
let row = sqlx::query_as::<_, SkillRow>(&format!(
"INSERT INTO taught_skills (id, space_id, user_id, bot_id, thread_id, name, goal, status, playbook)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'saved', $8)
RETURNING {COLUMNS}"
))
.bind(&skill_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(&bot_id)
.bind(&thread_id)
.bind(&name)
.bind(&goal)
.bind(&playbook)
.fetch_one(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok(Json(row.into()))
}
async fn teach_target(
state: &AppState,
actor: &Actor,
@ -1448,4 +1716,60 @@ mod tests {
assert!(text.contains("1. open → expect: page"));
assert!(text.contains("never replay coordinates"));
}
fn sample_playbook() -> Value {
json!({
"name": "完成 STAR 訓練",
"whenToUse": "要把指定課程看完",
"intent": "把 STAR 課程的影片看完並通過測驗",
"inputs": [{"name": "courseName", "description": "課程名稱", "example": "Workplace"}],
"preconditions": ["已登入"],
"steps": [{"do": "點 Next", "expect": "頁碼加一", "note": "鎖住就等"}],
"howToCheck": "課程顯示 completed",
"whatToReturn": "課程名稱與結果",
"cautions": ["遇到驗證碼就停下"],
"noise": "drop me"
})
}
#[test]
fn skill_file_roundtrip_drops_unknown_keys() {
let playbook = sample_playbook();
let file = skill_file("完成 STAR 訓練", "示範目標", &playbook);
assert_eq!(file["kind"], SKILL_FILE_KIND);
assert_eq!(file["version"], SKILL_FILE_VERSION);
let (name, goal, parsed) = parse_skill_file(&file).unwrap();
assert_eq!(name, "完成 STAR 訓練");
assert_eq!(goal, "示範目標");
assert_eq!(parsed["steps"][0]["do"], "點 Next");
assert_eq!(parsed["inputs"][0]["name"], "courseName");
assert!(parsed.get("noise").is_none());
}
#[test]
fn import_accepts_bare_playbook() {
let (name, goal, playbook) = parse_skill_file(&sample_playbook()).unwrap();
assert_eq!(name, "完成 STAR 訓練");
assert_eq!(goal, "把 STAR 課程的影片看完並通過測驗");
assert_eq!(playbook["steps"].as_array().unwrap().len(), 1);
}
#[test]
fn import_rejects_wrong_kind_and_empty_steps() {
assert!(
parse_skill_file(&json!({"kind":"other","name":"a","steps":[{"do":"x"}]})).is_err()
);
assert!(parse_skill_file(&json!({"name":"a","steps":[]})).is_err());
assert!(parse_skill_file(&json!({"steps":[{"do":"x"}]})).is_err());
}
#[test]
fn unique_name_adds_suffix() {
let have = vec!["完成 STAR 訓練".into(), "完成 STAR 訓練 (2)".into()];
assert_eq!(
unique_skill_name(&have, "完成 STAR 訓練"),
"完成 STAR 訓練 (3)"
);
assert_eq!(unique_skill_name(&have, "新技能"), "新技能");
}
}

View File

@ -6,8 +6,7 @@ use lazyboy_contracts::{
use lazyboy_control::{
ActionError, ActionRequest, AdapterContext, CdpPage, CommandRequest, ComputerRef,
SandboxProvider, apply_element_targets, cdp_command_on, element_id, format_ui_elements,
frames_match,
merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
frames_match, merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
resolve_bot_workspace_cwd, resolve_bot_workspace_path,
};
use rig_core::completion::ToolDefinition;

View File

@ -55,4 +55,16 @@ pub struct SendSessionMessageInput {
pub client_nonce: Option<String>,
#[serde(default)]
pub blocks: Vec<Value>,
#[serde(default)]
pub attachments: Vec<SessionAttachment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SessionAttachment {
pub name: String,
#[serde(default)]
pub mime_type: String,
#[serde(alias = "contentBase64")]
pub content: String,
}