diff --git a/.env.example b/.env.example index 7a39024..eab494b 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,17 @@ LAZYBOY_COMPUTER_SUDO=false # Linux only (optional): point this at the host's LXCFS root to make htop/free # report the per-Agent cgroup quota. Leave the default empty directory on macOS. LAZYBOY_LXCFS_ROOT=./data/lxcfs +# 任務長度政策:不再用固定輪數掐掉任務。正常任務一路做到驗證完成,只有 +# 真的鬼打牆(同一個動作重複、同一個錯誤一直失敗、很久沒有新的成功)才會被 +# 提示、接著暫停等你決定;最後兩個是防迴圈失控烧 token 的保險絲,不是額度。 +# soft turns:第 60 輪起,之後每 soft every 輪請模型自我交代「已完成/還缺/下一步」 +# cap turns / hard minutes:最後防火牆,正常任務不該碰到 +LAZYBOY_RUN_SOFT_TURNS=60 +LAZYBOY_RUN_SOFT_EVERY=120 +LAZYBOY_RUN_CAP_TURNS=1000 +LAZYBOY_RUN_SOFT_MINUTES=75 +LAZYBOY_RUN_HARD_MINUTES=240 + LAZYBOY_MEMORY_ENABLED=true LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed LAZYBOY_MEMORY_TOP_K=8 diff --git a/Cargo.toml b/Cargo.toml index ffb8e7e..1aeef69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "serd image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } rig-core = "0.42" async-trait = "0.1" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time", "sync"] } axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } tracing = "0.1" diff --git a/README.md b/README.md index a66b0e7..461eaf0 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ The guides below are currently in Traditional Chinese. | [Architecture](./docs/architecture.md) | Task flow, system architecture, computer lifecycle | | [Interactive diagram](./docs/workflow.html) | Zoomable, searchable HTML chart; download and open | | [Operations](./docs/operations.md) | Resources, env vars, security, site checks, sudo | +| [Agent experience](./docs/agent-experience.md) | Turn limits, persistent terminal, live chat | | [Development](./docs/development.md) | Local dev, checks and tests, directory layout | | [Env example](./.env.example) | Environment variables and defaults | diff --git a/README.zh-TW.md b/README.zh-TW.md index 8f1add3..b79e244 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -111,6 +111,7 @@ npm run dev | [架構與流程](./docs/architecture.md) | 任務流程圖、系統架構、電腦生命週期狀態機 | | [互動流程圖](./docs/workflow.html) | 可縮放、搜尋的 HTML 圖表;下載後開啟 | | [部署與操作](./docs/operations.md) | 資源、環境變數、安全設定、網站驗證、sudo | +| [AI 使用體驗](./docs/agent-experience.md) | 輪次政策、持久終端機、聊天即時推送 | | [開發指南](./docs/development.md) | 本機開發、檢查與測試、目錄結構 | | [設定範例](./.env.example) | 環境變數與預設值 | diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index df10d6b..3447b9d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,11 +15,12 @@ import visibility from "react-useanimations/lib/visibility"; import visibility2 from "react-useanimations/lib/visibility2"; import searchToX from "react-useanimations/lib/searchToX"; import { api, ApiError } from "./api"; +import { createCoalescer, subscribeToSession } from "./live"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; import { ChatMarkdown, CopyMessageButton } from "./markdown"; -import { RunStatus, RunProbe, errorActions, errorTitle } from "./run-monitor"; +import { RunProbe, errorActions, errorTitle } from "./run-monitor"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule"; import { CallOverlay, PhoneIcon } from "./call"; import { VoiceSettingsDialog } from "./voice-settings"; @@ -28,6 +29,21 @@ const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",control const SESSION_STORE="lazyboy.sessionByBot"; const PANE_STORE="lazyboy.rightPane"; const WORKSPACE_STORE="lazyboy.workspace"; +// Refresh policy: the session's event stream decides when the transcript is +// stale, so the timer is only a backstop - it runs slowly while the stream is +// up and speeds back up if the stream drops. One turn can move several events, +// which is why they settle into a single refresh instead of one fetch each. +// The backstop is also the only freshness guarantee for the computer banner: +// computers have no events of their own, so this interval is how long a state +// change nobody sent a message for (idle park, a crash, another user taking +// control) can stay invisible. Four seconds is still lighter than the pre-push +// tick, which paid for the whole transcript plus a heartbeat every two. +const EVENT_SETTLE_MS=120; +const LIVE_POLL_MS=4000; +const FALLBACK_POLL_MS=2000; +// A heartbeat buys a 15 minute control lease and keeps the 10 minute idle +// cutoff from parking the computer, so a minute between beats is a wide margin. +const HEARTBEAT_MS=60000; type RightPart="computer"|"memory"|"settings"|"plugins"|"accounts"; type AccountDialog="phone"|"settings"|"model"|"voice"|"about"|"help"|"feedback"|null; function readSessionStore():Record{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record:{}}catch{return {}}} @@ -92,7 +108,7 @@ function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?n 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}]})} type MessageChip={kind:string;site?:string;why?:string;name?:string;human?:string;cron?:string;reason?:string;turns?:number;limit?:number;code?:string;retryable?:boolean;runId?:string;turn?:number;step?:string|null}; function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as MessageChip[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as MessageChip;if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun"||value.kind==="resume"||value.kind==="error")return [value];return []})} -function resumeTitle(reason?:string){return reason==="budget_exhausted"?t("resumeBudget"):t("resumeMidTask")} +function resumeTitle(reason?:string){return reason==="budget_exhausted"?t("resumeBudget"):reason==="loop_detected"?t("resumeLoop"):t("resumeMidTask")} function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===`Attached ${file.name}`||text===t("attachedFile",{name:file.name}))} function isAutoScheduleCaption(body:string,chips:{kind?:string}[]){if(!chips.some(chip=>chip.kind==="schedule"||chip.kind==="scheduleRun"))return false;const text=body.trim();return /^\[排程(試跑)?\]/.test(text)||/^\[Schedule( test)?\]/i.test(text)} function FileCard({file,preview,onRemove}:{file:{name:string;size?:number};preview?:string|null;onRemove?:()=>void}){const ext=fileExt(file.name);return
{preview?:}
{file.name}{typeof file.size==="number"?formatBytes(file.size):ext}
{onRemove&&}
} @@ -208,7 +224,24 @@ export function App(){ useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current="";setPendingFiles(current=>{current.forEach(file=>file.preview&&URL.revokeObjectURL(file.preview));return []})},[activeSessionId]); useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]); useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]); - useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(localizeError(e.message)));const timer=setInterval(()=>{refresh().catch(()=>{});const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>{clearInterval(timer);refreshSeqRef.current+=1}},[activeId,activeRoomId,activeSessionId,refresh]); + // Live transcript: the event stream says when to look, so a reply appears as + // soon as it is written. Polling stays as a backstop that cannot starve the + // database, and a hidden tab stops re-reading until it comes back to the + // foreground - which is itself a "what happened" moment worth refreshing on. + useEffect(()=>{ + if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return} + setScreenUrl(null); + refresh().catch(e=>setError(localizeError(e.message))); + const settle=createCoalescer(()=>{if(!document.hidden)refresh().catch(()=>{})},EVENT_SETTLE_MS); + let live=false,opened=false,timer=0; + const tick=()=>{if(!document.hidden)refresh().catch(()=>{});timer=window.setTimeout(tick,live?LIVE_POLL_MS:FALLBACK_POLL_MS)}; + timer=window.setTimeout(tick,FALLBACK_POLL_MS); + const heartbeat=window.setInterval(()=>{const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},HEARTBEAT_MS); + const feed=subscribeToSession(activeSessionId,()=>settle.kick(),{onStatus:connected=>{live=connected;if(connected&&opened)refresh().catch(()=>{});opened=true}}); + const resume=()=>{if(!document.hidden)refresh().catch(()=>{})}; + document.addEventListener("visibilitychange",resume); + return()=>{window.clearTimeout(timer);window.clearInterval(heartbeat);document.removeEventListener("visibilitychange",resume);feed.close();settle.cancel();refreshSeqRef.current+=1}; + },[activeId,activeRoomId,activeSessionId,refresh]); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus(t("clipboardSynced"))).catch(()=>setClipboardStatus(t("clipboardSyncBlocked")))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-mobile-key"&&typeof event.data.key==="string"&&/^(?:(?:ctrl|alt)\+)?(?:Return|BackSpace|Tab|Escape|Left|Right|Up|Down|[acvz])$/.test(event.data.key))queueDesktopInput({kind:"key",key:event.data.key});if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)}); useEffect(()=>{setDesktopReady(false)},[screenUrl,paneBotId]); useEffect(()=>{if(skipHandoffRef.current){skipHandoffRef.current=false;holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId;return}if((holderRef.current!==computer.controlHolder||paneBotRef.current!==paneBotId)&&computer.state==="running")setHandingOff(true);holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId},[computer.controlHolder,paneBotId,computer.state]); @@ -329,9 +362,6 @@ export function App(){ const overlayLabel=hudLabel(computer,connecting,handingOff); const frame=screenUrl?