fix skill
This commit is contained in:
parent
f48b55d114
commit
a0572099d0
|
|
@ -1,5 +1,5 @@
|
||||||
import { FormEvent, KeyboardEvent as ReactKeyboardEvent, 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, 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 UseAnimations from "react-useanimations";
|
||||||
import loading from "react-useanimations/lib/loading";
|
import loading from "react-useanimations/lib/loading";
|
||||||
import loading2 from "react-useanimations/lib/loading2";
|
import loading2 from "react-useanimations/lib/loading2";
|
||||||
|
|
@ -18,7 +18,7 @@ import searchToX from "react-useanimations/lib/searchToX";
|
||||||
import { api, ApiError } from "./api";
|
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 { 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 { 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 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";
|
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 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 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)}
|
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(){
|
function clientNonce(){
|
||||||
const webCrypto=globalThis.crypto;
|
const webCrypto=globalThis.crypto;
|
||||||
if(webCrypto&&typeof webCrypto.randomUUID==="function")return webCrypto.randomUUID();
|
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 [roomContext,setRoomContext]=useState<{room:Room;x:number;y:number}|null>(null);
|
||||||
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
|
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
|
||||||
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
|
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 [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
|
||||||
const [looks,setLooks]=useState(readAvatarLooks);
|
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 messageEndRef=useRef<HTMLDivElement|null>(null);
|
||||||
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
|
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
|
||||||
const roomsRef=useRef(rooms);
|
const roomsRef=useRef(rooms);
|
||||||
|
|
@ -91,6 +103,8 @@ export function App(){
|
||||||
const drafting=skills.find(skill=>skill.status==="drafting")||null;
|
const drafting=skills.find(skill=>skill.status==="drafting")||null;
|
||||||
const skillDraft=teaching||drafting?null:skills.find(skill=>skill.status==="draft")||null;
|
const skillDraft=teaching||drafting?null:skills.find(skill=>skill.status==="draft")||null;
|
||||||
const savedSkills=skills.filter(skill=>skill.status==="saved");
|
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 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]);
|
||||||
|
|
@ -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(()=>{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(()=>{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(()=>{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(()=>{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)});
|
||||||
|
|
@ -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)}
|
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;
|
const editingSkill=editingSkillId?skills.find(skill=>skill.id===editingSkillId)||null:null;
|
||||||
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}執行「${skill.name}」`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
|
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}執行「${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 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)}}
|
||||||
|
|
@ -205,10 +223,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">{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>}
|
{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}><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>
|
</main>
|
||||||
|
|
||||||
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}
|
{!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>}
|
{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)}/>}
|
{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)}}/>}
|
{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)}}/>}
|
{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>;
|
</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 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 [name,setName]=useState(skill.name||skill.playbook.name||"");
|
||||||
const [stepsText,setStepsText]=useState((skill.playbook.steps||[]).map(step=>stepText(step)).join("\n"));
|
const [stepsText,setStepsText]=useState((skill.playbook.steps||[]).map(step=>stepText(step)).join("\n"));
|
||||||
const [expanded,setExpanded]=useState(false);
|
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>
|
<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.playbook.howToCheck&&<p className="skill-check">{t("skillCheck")}:{skill.playbook.howToCheck}</p>}
|
||||||
{skill.error&&<p className="skill-error">{t("skillDistillFailed")}</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>;
|
</section>;
|
||||||
}
|
}
|
||||||
const lines=(text:string)=>text.split("\n").map(line=>line.replace(/^\s*(?:\d+[.、)]|[-•*])\s*/,"").trim()).filter(Boolean);
|
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 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 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 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 p=skill.playbook;
|
||||||
const [name,setName]=useState(skill.name||p.name||"");
|
const [name,setName]=useState(skill.name||p.name||"");
|
||||||
const [intent,setIntent]=useState(p.intent||"");
|
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("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("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>
|
<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>;
|
</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>}
|
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("helpComputerTitle")}</h3><p>{t("helpComputer")}</p></section>
|
||||||
<section><h3>{t("helpMemoryTitle")}</h3><p>{t("helpMemory")}</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("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>
|
<section><h3>{t("helpShortcutsTitle")}</h3><p>{t("helpShortcuts")}</p></section>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
|
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,10 @@ export const Smartphone=animatedIcon(video,16);
|
||||||
export const Square=animatedIcon(playPause,14);
|
export const Square=animatedIcon(playPause,14);
|
||||||
export const Users=animatedIcon(userPlus,17);
|
export const Users=animatedIcon(userPlus,17);
|
||||||
export const X=animatedIcon(plusToX,16,true);
|
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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,11 @@ export const zhTW = {
|
||||||
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
|
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
|
||||||
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
|
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
|
||||||
pausedNeedsUser: "{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: "已學會的技能",
|
teachTask: "教它一項任務", teachTaskHint: "你示範一次,它學成技能", teachNeedsBot: "先選擇一個機器人", taughtSkills: "已學會的技能",
|
||||||
|
searchSkills: "搜尋技能", noMatchingSkills: "沒有符合的技能",
|
||||||
teachDialogLead: "接下來畫面交給你操作,{name} 會在旁邊看:記錄你點了哪些控制項、輸入了什麼、去了哪些頁面,之後整理成一個「知道目的與流程」的技能,而不是死記座標。",
|
teachDialogLead: "接下來畫面交給你操作,{name} 會在旁邊看:記錄你點了哪些控制項、輸入了什麼、去了哪些頁面,之後整理成一個「知道目的與流程」的技能,而不是死記座標。",
|
||||||
teachGoalLabel: "你要示範什麼?(一句話說目標)", teachGoalPlaceholder: "例如:到 STAR 訓練系統,把指定課程的影片看完並完成測驗",
|
teachGoalLabel: "你要示範什麼?(一句話說目標)", teachGoalPlaceholder: "例如:到 STAR 訓練系統,把指定課程的影片看完並完成測驗",
|
||||||
teachTip1: "用平常的方式操作即可,不用刻意放慢;多餘的點擊會被忽略。", teachTip2: "會變動的值(搜尋字、名稱、日期)之後可以當作參數,做的時候先用實際例子。", teachTip3: "密碼欄位不會被記錄;遇到登入請先登好再開始示範。",
|
teachTip1: "用平常的方式操作即可,不用刻意放慢;多餘的點擊會被忽略。", teachTip2: "會變動的值(搜尋字、名稱、日期)之後可以當作參數,做的時候先用實際例子。", teachTip3: "密碼欄位不會被記錄;遇到登入請先登好再開始示範。",
|
||||||
|
|
@ -32,6 +35,9 @@ export const zhTW = {
|
||||||
skillIntent: "意圖(這個技能要達成什麼)", skillWhenToUse: "使用時機", skillInputsLabel: "可變輸入(每行:名稱:說明|例:範例值)", skillInputsPlaceholder: "courseName:課程名稱|例:【TW】Workplace Bullying Prevention (2026)",
|
skillIntent: "意圖(這個技能要達成什麼)", skillWhenToUse: "使用時機", skillInputsLabel: "可變輸入(每行:名稱:說明|例:範例值)", skillInputsPlaceholder: "courseName:課程名稱|例:【TW】Workplace Bullying Prevention (2026)",
|
||||||
skillPreconditions: "前提(開始前要成立的條件)", skillStepsLabel: "步驟(每行一步,可用 → 接上預期看到的畫面)", skillStepsPlaceholder: "點「Next」翻頁 → 頁碼加一;若 Next 鎖住就等它啟用", skillHowToCheck: "怎麼確認完成", skillWhatToReturn: "做完要回報什麼", skillCautions: "注意事項(每行一項)",
|
skillPreconditions: "前提(開始前要成立的條件)", skillStepsLabel: "步驟(每行一步,可用 → 接上預期看到的畫面)", skillStepsPlaceholder: "點「Next」翻頁 → 頁碼加一;若 Next 鎖住就等它啟用", skillHowToCheck: "怎麼確認完成", skillWhatToReturn: "做完要回報什麼", skillCautions: "注意事項(每行一項)",
|
||||||
deleteSkill: "刪除技能", deleteSkillConfirm: "確定刪除技能「{name}」?機器人之後就不會再認得它。", skillSaved: "已儲存",
|
deleteSkill: "刪除技能", deleteSkillConfirm: "確定刪除技能「{name}」?機器人之後就不會再認得它。", skillSaved: "已儲存",
|
||||||
|
exportSkill: "匯出", exportSkillHint: "下載成 JSON,之後可以匯入到其他機器人",
|
||||||
|
importSkill: "匯入技能", importSkillHint: "從先前匯出的 JSON 檔加入技能",
|
||||||
|
skillImportInvalid: "這不是有效的技能檔。請選擇先前匯出的 JSON。",
|
||||||
collapseSidebar: "收合側欄", botComputer: "{name} 的電腦", dedicatedScreen: "獨立螢幕", enlarge: "放大",
|
collapseSidebar: "收合側欄", botComputer: "{name} 的電腦", dedicatedScreen: "獨立螢幕", enlarge: "放大",
|
||||||
userControlling: "你正在控制", aiReadOnly: "AI 操作中(唯讀)", readOnly: "唯讀", pasteClipboard: "貼上剪貼簿", copyDesktopClipboard: "複製桌面剪貼簿", moreActions: "更多操作",
|
userControlling: "你正在控制", aiReadOnly: "AI 操作中(唯讀)", readOnly: "唯讀", pasteClipboard: "貼上剪貼簿", copyDesktopClipboard: "複製桌面剪貼簿", moreActions: "更多操作",
|
||||||
unpin: "取消釘選", pin: "釘選", markUnread: "標示為未讀", enterGroupName: "輸入分組名稱", changeGroup: "變更分組", createOrMoveGroup: "建立/移入分組", removeFromGroup: "移出分組", unhide: "取消隱藏", hide: "隱藏", delete: "刪除",
|
unpin: "取消釘選", pin: "釘選", markUnread: "標示為未讀", enterGroupName: "輸入分組名稱", changeGroup: "變更分組", createOrMoveGroup: "建立/移入分組", removeFromGroup: "移出分組", unhide: "取消隱藏", hide: "隱藏", delete: "刪除",
|
||||||
|
|
@ -81,7 +87,10 @@ export const zhTW = {
|
||||||
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
||||||
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
|
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
|
||||||
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 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: "複製內容",
|
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
|
||||||
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
|
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
|
||||||
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
|
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
|
||||||
|
|
|
||||||
|
|
@ -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{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}
|
.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{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 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}
|
.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}
|
.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}
|
.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 */
|
/* Teach-by-demonstration: composer menu, live banner, draft card */
|
||||||
.plus-menu-wrap{position:relative;align-self:center}
|
.plus-menu-wrap{position:relative;align-self:center}
|
||||||
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}
|
.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{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:hover:not(:disabled){background:rgba(255,255,255,.07)}
|
||||||
.plus-menu button:disabled{opacity:.4;cursor:not-allowed}
|
.plus-menu button:disabled{opacity:.4;cursor:not-allowed}
|
||||||
.plus-menu svg{width:16px;height:16px;flex:0 0 16px}
|
.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 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-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{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}
|
.record-dot.live{animation:pulse 1.2s infinite;margin-right:8px;vertical-align:-1px}
|
||||||
.teach-dialog h2{display:flex;align-items:center;gap:10px}
|
.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{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:hover{color:var(--ink)}
|
||||||
.plus-menu-skill .skill-edit svg{width:14px;height:14px;flex-basis:14px}
|
.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 */
|
/* Full skill editor */
|
||||||
.dialog.skill-edit{width:min(600px,calc(100vw - 28px));max-height:calc(100dvh - 28px);overflow:auto}
|
.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}
|
.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 label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
|
||||||
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;font-size:13.5px;line-height:1.5;resize:vertical}
|
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:#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 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 .grow{flex:1}
|
||||||
|
.dialog.skill-edit .dialog-actions .outline svg{fill:none;stroke:currentColor;width:14px;height:14px}
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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 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 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 }
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -932,6 +932,7 @@ pub fn user_has_screen_control(
|
||||||
pub async fn idle_loop(state: AppState) {
|
pub async fn idle_loop(state: AppState) {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
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 cutoff = Utc::now() - TimeDelta::minutes(10);
|
||||||
let rows = sqlx::query_as::<_, ComputerRow>(
|
let rows = sqlx::query_as::<_, ComputerRow>(
|
||||||
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
|
"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())
|
.fetch_all(state.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let waiting = active
|
let waiting = active.iter().find(|run| run.status == "waiting_takeover");
|
||||||
.iter()
|
|
||||||
.find(|run| run.status == "waiting_takeover");
|
|
||||||
let busy = active.iter().find(|run| {
|
let busy = active.iter().find(|run| {
|
||||||
parse_run_status(&run.status).is_some_and(|status| {
|
parse_run_status(&run.status).is_some_and(|status| {
|
||||||
status.is_active() && status != lazyboy_contracts::RunStatus::WaitingTakeover
|
status.is_active() && status != lazyboy_contracts::RunStatus::WaitingTakeover
|
||||||
|
|
@ -1077,7 +1076,9 @@ pub async fn current_status(
|
||||||
}
|
}
|
||||||
status.queued_runs = active
|
status.queued_runs = active
|
||||||
.iter()
|
.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;
|
.count() as u32;
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod attachments;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod computer;
|
mod computer;
|
||||||
mod db;
|
mod db;
|
||||||
|
|
@ -18,6 +19,7 @@ use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
@ -72,7 +74,8 @@ async fn main() {
|
||||||
)
|
)
|
||||||
.merge(auth::public_router(state.clone()))
|
.merge(auth::public_router(state.clone()))
|
||||||
.merge(routes::router(state))
|
.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}");
|
tracing::info!("api listening on {addr}");
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
|
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,8 @@ struct SendBody {
|
||||||
client_nonce: Option<String>,
|
client_nonce: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
blocks: Vec<Value>,
|
blocks: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
attachments: Vec<lazyboy_contracts::SessionAttachment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
|
|
@ -504,6 +506,7 @@ async fn send_message(
|
||||||
&body.text,
|
&body.text,
|
||||||
body.client_nonce.as_deref(),
|
body.client_nonce.as_deref(),
|
||||||
&body.blocks,
|
&body.blocks,
|
||||||
|
&body.attachments,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use lazyboy_contracts::ModelProvider;
|
use lazyboy_contracts::{ModelProvider, SessionAttachment};
|
||||||
use lazyboy_harness::{
|
use lazyboy_harness::{
|
||||||
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
|
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
|
||||||
};
|
};
|
||||||
|
|
@ -54,7 +54,7 @@ computer_act examples (native windows only):
|
||||||
- {\"kind\":\"focus\",\"title\":\"Open File\"}
|
- {\"kind\":\"focus\",\"title\":\"Open File\"}
|
||||||
- wait: {\"seconds\":30,\"reason\":\"video playing\"}
|
- 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(
|
pub async fn send(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|
@ -64,6 +64,7 @@ pub async fn send(
|
||||||
text: &str,
|
text: &str,
|
||||||
client_nonce: Option<&str>,
|
client_nonce: Option<&str>,
|
||||||
blocks: &[Value],
|
blocks: &[Value],
|
||||||
|
attachments: &[SessionAttachment],
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
if crate::skills::recording_skill(state.pool(), bot_id)
|
if crate::skills::recording_skill(state.pool(), bot_id)
|
||||||
.await
|
.await
|
||||||
|
|
@ -71,6 +72,13 @@ pub async fn send(
|
||||||
{
|
{
|
||||||
return Err("示範進行中:先按「完成示範」或「取消」,再送訊息。".into());
|
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
|
let mut tx = state
|
||||||
.pool()
|
.pool()
|
||||||
.begin()
|
.begin()
|
||||||
|
|
@ -129,8 +137,8 @@ pub async fn send(
|
||||||
.bind(&message_id)
|
.bind(&message_id)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
.bind(seq)
|
.bind(seq)
|
||||||
.bind(text)
|
.bind(&stored_body)
|
||||||
.bind(json!(blocks))
|
.bind(json!(stored_blocks))
|
||||||
.bind(&run_id)
|
.bind(&run_id)
|
||||||
.bind(client_nonce)
|
.bind(client_nonce)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
|
|
@ -139,7 +147,7 @@ pub async fn send(
|
||||||
if crate::sessions::is_default_session_title(¤t_title) {
|
if crate::sessions::is_default_session_title(¤t_title) {
|
||||||
sqlx::query("UPDATE threads SET title=$2 WHERE id=$1")
|
sqlx::query("UPDATE threads SET title=$2 WHERE id=$1")
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
.bind(crate::sessions::title_from_first_message(text))
|
.bind(crate::sessions::title_from_first_message(&stored_body))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
@ -171,7 +179,7 @@ pub async fn send(
|
||||||
.bind(member_id)
|
.bind(member_id)
|
||||||
.bind(&thread_id)
|
.bind(&thread_id)
|
||||||
.bind(&actor.user_id)
|
.bind(&actor.user_id)
|
||||||
.bind(text)
|
.bind(&stored_body)
|
||||||
.bind(json!({"messageSeq":seq}))
|
.bind(json!({"messageSeq":seq}))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
|
|
@ -190,7 +198,7 @@ pub async fn send(
|
||||||
.bind(Uuid::new_v4().to_string())
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
.bind(event_seq)
|
.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)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
@ -211,6 +219,11 @@ pub async fn send(
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
}
|
}
|
||||||
tx.commit().await.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!({
|
Ok(json!({
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
"runId": run_id,
|
"runId": run_id,
|
||||||
|
|
@ -498,12 +511,28 @@ async fn execute_run(
|
||||||
} else {
|
} else {
|
||||||
vec![UserContent::text(prompt)]
|
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;
|
let mut skill_check: Option<String> = None;
|
||||||
if !resume_after_takeover {
|
if !resume_after_takeover {
|
||||||
// The user named a taught skill: hand the model the full playbook up
|
// 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.
|
// 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 {
|
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));
|
skill_check = Some(crate::skills::skill_check_hint(&skill));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -677,7 +706,12 @@ async fn execute_run(
|
||||||
match nudge {
|
match nudge {
|
||||||
Some(text) if nudges < 6 && turns + 2 < max_turns => {
|
Some(text) if nudges < 6 && turns + 2 < max_turns => {
|
||||||
nudges += 1;
|
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());
|
earlier_replies.push(final_text.trim().to_string());
|
||||||
final_text.clear();
|
final_text.clear();
|
||||||
let mut content = vec![UserContent::text(text)];
|
let mut content = vec![UserContent::text(text)];
|
||||||
|
|
@ -718,7 +752,12 @@ async fn execute_run(
|
||||||
let name = call.function.name.clone();
|
let name = call.function.name.clone();
|
||||||
used_gui |= matches!(
|
used_gui |= matches!(
|
||||||
name.as_str(),
|
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);
|
let step = describe_step(&name, &call.function.arguments);
|
||||||
set_run_step(state, run_id, &step).await;
|
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
|
/// Cancel every unfinished run of a bot and free the screen/execution leases
|
||||||
/// it held, so the desktop is available to a human immediately.
|
/// 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(
|
let run_ids: Vec<String> = sqlx::query_scalar(
|
||||||
"UPDATE runs SET status = 'cancelled', completed_at = now(), updated_at = now()
|
"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')
|
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 kind = field("kind").or(field("type")).unwrap_or("?");
|
||||||
let target = if let Some(text) = field("text").or(field("keys")) {
|
let target = if let Some(text) = field("text").or(field("keys")) {
|
||||||
short(Some(text), 24)
|
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}")
|
format!("#{id}")
|
||||||
} else if let (Some(x), Some(y)) = (
|
} else if let (Some(x), Some(y)) = (
|
||||||
action.get("x").and_then(Value::as_i64),
|
action.get("x").and_then(Value::as_i64),
|
||||||
|
|
@ -1262,16 +1305,28 @@ fn describe_step(name: &str, args: &Value) -> String {
|
||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
"browser" => {
|
"browser" => {
|
||||||
let target = get("url").or(get("text")).or(get("selector")).map(|s| short(Some(s), 40));
|
let target = get("url")
|
||||||
let target = target.or_else(|| lazyboy_control::element_id(args.get("element")).map(|id| format!("#{id}")));
|
.or(get("text"))
|
||||||
format!("{} {}", get("action").unwrap_or("snapshot"), target.unwrap_or_default())
|
.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()
|
.trim()
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
"shell" => short(get("command").or(get("cmd")), 60),
|
"shell" => short(get("command").or(get("cmd")), 60),
|
||||||
"wait" => format!(
|
"wait" => format!(
|
||||||
"{}s {}",
|
"{}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)
|
short(get("reason"), 30)
|
||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -1349,7 +1404,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn step_labels_summarize_tool_arguments() {
|
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!(
|
assert_eq!(
|
||||||
describe_step(
|
describe_step(
|
||||||
"computer_act",
|
"computer_act",
|
||||||
|
|
@ -1365,7 +1423,10 @@ mod tests {
|
||||||
describe_step("shell", &json!({"command":"ls\n-la"})),
|
describe_step("shell", &json!({"command":"ls\n-la"})),
|
||||||
"shell: ls -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]
|
#[test]
|
||||||
|
|
@ -1419,7 +1480,11 @@ mod tests {
|
||||||
let Message::User { content } = &history[2] else {
|
let Message::User { content } = &history[2] else {
|
||||||
panic!("expected user message");
|
panic!("expected user message");
|
||||||
};
|
};
|
||||||
assert!(content.iter().any(|part| matches!(part, UserContent::Image(_))));
|
assert!(
|
||||||
|
content
|
||||||
|
.iter()
|
||||||
|
.any(|part| matches!(part, UserContent::Image(_)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,7 @@ async fn send_message(
|
||||||
Json(input): Json<SendSessionMessageInput>,
|
Json(input): Json<SendSessionMessageInput>,
|
||||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||||
let actor = actor(&state).await?;
|
let actor = actor(&state).await?;
|
||||||
if input.text.trim().is_empty() {
|
if input.text.trim().is_empty() && input.attachments.is_empty() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(json!({"message":"empty message"})),
|
Json(json!({"message":"empty message"})),
|
||||||
|
|
@ -346,6 +346,7 @@ async fn send_message(
|
||||||
input.text.trim(),
|
input.text.trim(),
|
||||||
input.client_nonce.as_deref(),
|
input.client_nonce.as_deref(),
|
||||||
&input.blocks,
|
&input.blocks,
|
||||||
|
&input.attachments,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|message| (StatusCode::BAD_REQUEST, Json(json!({"message":message}))))?;
|
.map_err(|message| (StatusCode::BAD_REQUEST, Json(json!({"message":message}))))?;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||||
use axum::routing::{get, patch, post};
|
use axum::routing::{get, patch, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use base64::Engine;
|
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/start", post(start_skill))
|
||||||
.route("/api/bots/{id}/skills/stop", post(stop_skill))
|
.route("/api/bots/{id}/skills/stop", post(stop_skill))
|
||||||
.route("/api/bots/{id}/skills/cancel", post(cancel_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}", patch(update_skill).delete(delete_skill))
|
||||||
|
.route("/api/skills/{id}/export", get(export_skill))
|
||||||
.route("/api/skills/{id}/test", post(test_skill))
|
.route("/api/skills/{id}/test", post(test_skill))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,12 +476,278 @@ async fn test_skill(
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
.ok_or_else(not_found)?;
|
.ok_or_else(not_found)?;
|
||||||
let prompt = format!("試跑技能「{name}」:照剛學到的流程做一遍,做完回報結果。");
|
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
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
Ok(Json(result))
|
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(
|
async fn teach_target(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
actor: &Actor,
|
actor: &Actor,
|
||||||
|
|
@ -1448,4 +1716,60 @@ mod tests {
|
||||||
assert!(text.contains("1. open → expect: page"));
|
assert!(text.contains("1. open → expect: page"));
|
||||||
assert!(text.contains("never replay coordinates"));
|
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, "新技能"), "新技能");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ use lazyboy_contracts::{
|
||||||
use lazyboy_control::{
|
use lazyboy_control::{
|
||||||
ActionError, ActionRequest, AdapterContext, CdpPage, CommandRequest, ComputerRef,
|
ActionError, ActionRequest, AdapterContext, CdpPage, CommandRequest, ComputerRef,
|
||||||
SandboxProvider, apply_element_targets, cdp_command_on, element_id, format_ui_elements,
|
SandboxProvider, apply_element_targets, cdp_command_on, element_id, format_ui_elements,
|
||||||
frames_match,
|
frames_match, merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
|
||||||
merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
|
|
||||||
resolve_bot_workspace_cwd, resolve_bot_workspace_path,
|
resolve_bot_workspace_cwd, resolve_bot_workspace_path,
|
||||||
};
|
};
|
||||||
use rig_core::completion::ToolDefinition;
|
use rig_core::completion::ToolDefinition;
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,16 @@ pub struct SendSessionMessageInput {
|
||||||
pub client_nonce: Option<String>,
|
pub client_nonce: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub blocks: Vec<Value>,
|
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue