From 303f26294ef2df1428d0a1e650c7f1766f51d7de Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 10 Sep 2026 22:29:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(agents):=20=E5=AE=8C=E5=96=84=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E8=B4=B9=E7=94=A8=E4=B8=8E=E4=BA=91=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...260910-personal-agent-platform-d15b2559.md | 29 ++-- README.md | 2 +- electron/api/routes/cloud-agents.ts | 12 +- electron/services/cloud-agent-journal.ts | 42 ++++++ electron/services/cloud-agent-operations.ts | 37 +++++- electron/services/cloud-agents.ts | 85 +++++++++++- shared/cloud-agents.ts | 54 +++++++- src/lib/cloud-agents-api.ts | 17 ++- src/pages/CloudAgents/CloudAccess.tsx | 52 +++++--- src/pages/CloudAgents/CloudChat.tsx | 95 +++++++++++--- src/pages/CloudAgents/CloudCosts.tsx | 85 ++++++++++++ src/pages/CloudAgents/CloudKnowledge.tsx | 65 ++++++++- src/pages/CloudAgents/CloudLifecycle.tsx | 59 +++++++++ src/pages/CloudAgents/CloudOverview.tsx | 18 ++- src/pages/CloudAgents/CloudRecovery.tsx | 42 ++++++ src/pages/CloudAgents/CloudResources.tsx | 110 ++++++++++++++++ src/pages/CloudAgents/CloudSchedules.tsx | 76 ++++++++--- src/pages/CloudAgents/ConfigurationFields.tsx | 4 +- src/pages/CloudAgents/index.tsx | 81 +++++++++--- tests/e2e/cloud-agents.spec.ts | 35 ++++- tests/unit/cloud-agents-main.test.ts | 62 ++++++++- tests/unit/cloud-agents-management.test.tsx | 83 ++++++++++++ tests/unit/cloud-agents-page.test.tsx | 28 +++- tests/unit/cloud-agents-workflows.test.tsx | 124 +++++++++++++++++- 24 files changed, 1170 insertions(+), 127 deletions(-) create mode 100644 electron/services/cloud-agent-journal.ts create mode 100644 src/pages/CloudAgents/CloudCosts.tsx create mode 100644 src/pages/CloudAgents/CloudLifecycle.tsx create mode 100644 src/pages/CloudAgents/CloudRecovery.tsx create mode 100644 src/pages/CloudAgents/CloudResources.tsx create mode 100644 tests/unit/cloud-agents-management.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260910-personal-agent-platform-d15b2559.md b/.project-docs/30-worklog/tasks/20260910-personal-agent-platform-d15b2559.md index 922e8e0..3af1606 100644 --- a/.project-docs/30-worklog/tasks/20260910-personal-agent-platform-d15b2559.md +++ b/.project-docs/30-worklog/tasks/20260910-personal-agent-platform-d15b2559.md @@ -12,6 +12,8 @@ ## Scope +- Resumed 2026-09-10 to finish the remaining-scope assessment, including product maintenance. Budget policy confirmed: optional per-Agent per-root-request and daily Token Points ceilings across all call sources, with no extra limit for existing Agents. The user authorized two bounded implementers and one fresh independent Reviewer for this batch; root owns client/integration/docs and all final Git operations. + - Complete the native cloud Agent workspace: configuration/preview, conversations/files/approval, publication/user sharing/application API, schedules/activity/cost; all cloud traffic and credentials remain Main-owned. - User explicitly requested all remaining first-release modules, not another draft-only slice. Build on the reviewed entry commit recorded above; verify all accepted personal/share/API/scheduled/goal-tool paths before claiming completion. @@ -25,27 +27,24 @@ ## Outcome -- Completed native Agents workspace: My/Received/Activity, configuration and preview, formal conversations, approval/questions, attachments and downloadable artifacts, personal KB documents/indexing, publication/share/application credentials, creator costs and scheduled goals/history. -- Main owns exchanged credentials, actual cloud transport/SSE, account cancellation, local file dialogs/writes and desktop notifications; Renderer uses typed Host operations. Unsaved/unsent input and uncertain requests are preserved through explicit leave/retry flows; niancode Agent links route through login and server authorization. -- Implementation and verification are complete. The user explicitly authorized one fresh independent read-only Reviewer and commits after fixes. The fresh Reviewer reviewed the full three-repository scope and returned final PASS after confirming all findings were fixed. No unresolved review finding remains; no production deployment, push or merge was performed. +- 已补齐 Main 按账号磁盘保存未确认操作/原输入及最近精确位置,恢复 UI 和排队状态;费用上限/统计、应用调用元数据、聊天日程建议与时间输入及启用确认;个人 MCP/Skill/子智能体、知识维护、归档、版本比较和旧版恢复。Renderer 只使用 typed Host API,凭据不写恢复日志。 +- 本批实现与验证已完成;获授权的全新只读 Reviewer completion_review(不继承开发上下文)审查三端完整增量,并复核待审批会话归档及手动日程默认停用两处修复后返回 PASS,无未解决 actionable finding。 +- 完整补全结果、行为边界和发布验收范围记录在 WS 同任务 evidence 的 `20260910-personal-agent-platform-60a1e3e6__completion-evidence.md`。旧 `__remaining-scope.md` 已标为历史核对。 ## Verification -- Passed: 32 tests across Main, draft/UI workflows and deep links. Includes stable retry intent, native batched SSE, approval restore, schedule enable intent, cost-service partial failure, actual temporary file bytes through mocked native dialogs, account switch cancellation and notification deduplication. -- Review fixes verified: new conversations appear immediately in history; unsent preview messages survive configuration edits; conversation changes protect approval answers and temporary attachments; cancelling file selection creates no empty conversation. The final attachment case verifies that only confirmation creates the conversation and associates the file. Renderer typecheck, targeted ESLint and Vite build passed again after this last fix. -- Passed: Renderer TypeScript; Vite build for Renderer/Main/preload/utility; real Electron Playwright scenario creates/saves/publishes an Agent, shows formal Markdown chat and creates an enabled schedule. The Host backend is explicitly stubbed in Electron coverage. -- Passed: targeted ESLint with no errors (2 non-blocking CloudChat warnings). Main TypeScript has the same 61 pre-existing errors, none in cloud Agents; this does not represent a clean repository-wide Main typecheck. -- Electron screenshots were inspected. No packaged installation, OS protocol registration or live three-service integration was executed. +- 本批 39 项 Main/页面/工作流/资源维护单元通过;真实 electron-store 临时磁盘重建 Main 后可读回,跨账号不可读,原操作标识保留且不自动重试。审查修复手动新建任务默认停用,与聊天建议一致;用户须显式勾选启用。 +- 最后补丁后 Renderer TypeScript、Vite 完整构建及改动 lint 通过;Electron Playwright 1 passed,覆盖创建/发布、Markdown 对话、恢复、费用、建议及手动新建默认关闭、确认启用。Host 后端明确为替身;截图已查看。 +- targeted ESLint 无错误、3 个非阻塞警告。Main 全仓 tsc 保留 61 个既有诊断,cloud Agent 文件没有诊断;不宣称全仓 Main 类型检查通过。 +- 没有安装包/系统协议注册、真实账号跨设备或真实三端生产验收。 ## Follow-ups -- 2026-09-10 用户要求核对剩余功能:本次只核对已提交代码、原交互方案及既有证据,没有修改运行代码或重跑产品测试。此前 Outcome 中“Implementation and verification are complete”仅适用于已实现的核心链路与已执行验证,不能代表原首期所有交互或 P6 已完成。 -- 核对结果:最近使用定位、聊天转自动任务建议、启用前完整说明、完整重启恢复和费用/应用使用统计未闭合;云资源维护另有产品完善项。 -- 三端完整剩余清单保存在 WS 本任务的 `50-evidence/topics/20260910-personal-agent-platform-60a1e3e6__remaining-scope.md`。恢复、交互和统计缺口是待实现事项;真实模型/沙盒/安装包是待验收事项,需分别跟踪。Ready for Integration 表示此源码及核对记录可交接,不表示产品已完成上线验收。 - -- Hand off the reviewed result as committed feature branches for a separately authorized integration/release. Keep the worktrees and branches; no push, merge, deployment or further Reviewer is implied. -- Release-environment follow-up: actual one-api/paid-model reconciliation, provisioner sandbox execution, installable client packaging and production migration/rollback exercise. Current local validation does not claim these deployment outcomes. +- 本批已完成独立审查与补丁验证;结果作为本地 feature 提交交接。三个任务的文档所有权边界检查通过,分支和工作树保留供后续集成。 +- 发布环境验收:真实 one-api/付费模型与创建者账单对账,完整服务进程/ARQ/provisioner 沙盒及重启恢复,安装包/OS 协议/跨设备流程,生产配置和迁移回退。 +- 费用按预占准入;供应商超预占进入现有 pending_review,不自动超额扣款。异常供应商报告与人工核对保持原账本契约,不能宣称供应商账单绝对封顶。 +- 保留隔离分支/工作树用于后续集成。未授权推送、合并、部署或操作生产数据;未执行这些动作。 ## Promotion Candidates -- Target: integrated state and owning Agent/auth/billing/runtime architecture records. Proposal: complete the user-approved personal cloud platform using existing Yuxi capability/Request/Run/scheduling and WS accounting owners; document the cloud module's scope alongside the existing WS Gateway. Evidence: this task's final source and meaningful HTTP/PG/worker/Electron validation. Future impact: future modules consume one runtime and one ledger. Semantic conflicts: earlier planning fields and draft-only scope are superseded by implementation; existing unrelated product contracts remain. Human confirmation: product direction is already approved; canonical promotion remains a serialized integration task. +- Target: 集成 current-state、原个人云平台决策及身份/计费/运行/资源架构。Proposal: 按最终补全源码与验证取代旧功能缺口及“每日预算未定”,记录按 Agent、根 Request、北京时间准入日计费和个人资源所有权。Evidence: 本任务三端代码、HTTP/PG/worker/Electron 结果及本批最终审查。Future impact: 后续功能复用同一运行体系和账本。Semantic conflicts: 原缺口清单仅为历史事实,原费用范围已被本次用户决定补足,不改变其他模块产品方向。Human confirmation: 产品范围已确认;共享文档提取必须走独立 Integration Gate,本 feature 不直接修改 accepted ADR/canonical memory。 diff --git a/README.md b/README.md index 1cad1f8..91ca899 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 -- `Makelore Agents|AI 智能体`:配置个人云智能体的模型、角色、工具、知识库、MCP、Skill、子智能体与执行限制;保存草稿并试聊,发布后正式对话、按账号分享或创建应用 API Key。自动任务支持目标、时间规则、审批恢复与运行历史;活动页汇总未读结果。对话支持流式回复、断线恢复、附件和产物下载,个人资料可上传并建立知识索引。所有模型调用由创建者个人词元点数支付,分享/API 调用者保有自己的内容空间;保存配置不扣点。Main 管理云会话和本机文件选择,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 +- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库支持上传、索引、替换、删除及导入本人会话附件;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。所有模型调用由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index d227d6f..aa53ec5 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -35,16 +35,24 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR res.end(); return true; } - if (path === '/attachments/pick' && req.method === 'POST') { + if (path === '/recovery' && req.method === 'GET') { + data = await cloudAgents.recovery(); + } else if (path === '/recovery/recent' && req.method === 'PUT') { + data = await cloudAgents.rememberRecent(await parseJsonBody(req)); + } else if (path === '/recovery/pending' && req.method === 'POST') { + data = await cloudAgents.resolvePending(await parseJsonBody(req)); + } else if (path === '/attachments/pick' && req.method === 'POST') { data = await cloudAgents.upload(); } else if (path === '/knowledge/pick' && req.method === 'POST') { data = await cloudAgents.uploadKnowledge(await parseJsonBody(req)); + } else if (path === '/skills/pick' && req.method === 'POST') { + data = await cloudAgents.uploadSkill(); } else if (path === '/files/save' && req.method === 'POST') { data = await cloudAgents.download(await parseJsonBody(req)); } else if (path === '/actions' && req.method === 'POST') { data = await cloudAgents.execute(await parseJsonBody(req)); } else if ((path === '/bootstrap' || path === '/agents') && req.method === 'GET') { - data = await cloudAgents.list(url.searchParams.get('cursor')); + data = await cloudAgents.list(url.searchParams.get('cursor'), url.searchParams.get('archived') === 'true'); } else if (path === '/agents' && req.method === 'POST') { data = await cloudAgents.create(await parseJsonBody(req)); } else { diff --git a/electron/services/cloud-agent-journal.ts b/electron/services/cloud-agent-journal.ts new file mode 100644 index 0000000..103863a --- /dev/null +++ b/electron/services/cloud-agent-journal.ts @@ -0,0 +1,42 @@ +import type { CloudPendingOperation, CloudRecoveryState, CloudRecent } from '../../shared/cloud-agents'; + +interface JournalStore { + get(key: string, fallback: CloudRecoveryState): CloudRecoveryState; + set(key: string, value: CloudRecoveryState): void; +} +let storePromise: Promise | undefined; +const loadStore = () => storePromise ??= import('electron-store').then(({ default: Store }) => + new Store>({ name: 'makelore-cloud-recovery' })); + +/** Main 保存当前账号的未确认业务输入;不保存凭据,也不自动重放。 */ +export class CloudAgentJournal { + constructor(private readonly store = loadStore) {} + + async read(accountKey: string): Promise { + return (await this.store()).get(accountKey, { recent: null, pending: [] }); + } + + private async update(accountKey: string, validate: () => void, change: (value: CloudRecoveryState) => CloudRecoveryState) { + const store = await this.store(); + validate(); + store.set(accountKey, change(store.get(accountKey, { recent: null, pending: [] }))); + } + + remember(accountKey: string, recent: CloudRecent | null, validate: () => void) { + return this.update(accountKey, validate, value => ({ ...value, recent })); + } + + put(accountKey: string, entry: CloudPendingOperation, validate: () => void) { + return this.update(accountKey, validate, value => { + const existing = value.pending.find(item => item.id === entry.id); + if (existing && JSON.stringify(existing.input) !== JSON.stringify(entry.input)) { + throw new Error('同一次待确认操作不能更改输入'); + } + return { ...value, pending: existing ? value.pending : [...value.pending, entry] }; + }); + } + + remove(accountKey: string, id: string, validate: () => void) { + return this.update(accountKey, validate, value => ({ ...value, pending: value.pending.filter(item => item.id !== id) })); + } +} diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index 365ac9d..f9f2523 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -9,8 +9,31 @@ const request = ['request_id', 'thread_id', 'run_id', 'status', 'version']; const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled']; const job = ['id', 'agent_slug', ...schedule, 'next_run_at', 'runs']; const entry = ['slug', 'name', 'purpose', 'published_version', 'is_creator', 'payer']; +const budget = ['agent_slug', 'unit', 'timezone', 'request_limit_points', 'daily_limit_points', 'daily_committed_points', 'resets_at']; +const draft = ['slug', 'name', 'purpose', 'system_prompt', 'draft_revision', 'updated_at', 'configuration', 'published_version', 'enabled', 'archived']; +const mcpInput = ['name', 'description', 'transport', 'url', 'headers']; +const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', 'has_credentials']; +const child = ['name', 'purpose', 'system_prompt']; /** Only these product operations can cross the Main boundary; no arbitrary upstream URL or credentials. */ const operations: Record = { + applicationCalls: { method: 'GET', path: '/applications/:application_id/calls', query: { offset: 'offset' }, output: ['items', 'next_offset'] }, + resources: { method: 'GET', path: '/resources', output: ['mcps', 'skills', 'subagents'] }, + createMcp: { method: 'POST', path: '/resources/mcps', body: [...mcpInput, 'operation_id'], output: mcpOutput }, + updateMcp: { method: 'PUT', path: '/resources/mcps/:key', body: mcpInput, output: mcpOutput }, + enableMcp: { method: 'PUT', path: '/resources/mcps/:key/enabled', body: ['enabled'], output: mcpOutput }, + deleteMcp: { method: 'DELETE', path: '/resources/mcps/:key', output: ['deleted'] }, + createChild: { method: 'POST', path: '/resources/subagents', body: [...child, 'operation_id'], output: ['slug', ...child] }, + updateChild: { method: 'PUT', path: '/resources/subagents/:key', body: child, output: ['slug', ...child] }, + confirmSkill: { method: 'POST', path: '/resources/skills/drafts/:draft_id/confirm', output: ['items'] }, + deleteSkill: { method: 'DELETE', path: '/resources/skills/:key', output: ['deleted'] }, + deleteKnowledge: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id', output: ['deleted'] }, + deleteKnowledgeFile: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id/files/:file_id', output: ['deleted'] }, + importKnowledgeAttachment: { method: 'POST', path: '/agents/:slug/knowledge/:kb_id/import-attachment', body: ['operation_id', 'thread_id', 'attachment_id'], output: ['file_id', 'name', 'size', 'status', 'error', 'chunk_count'] }, + archiveAgent: { method: 'PUT', path: '/agents/:slug/archive', body: ['archived'], output: draft }, + archiveThread: { method: 'PUT', path: '/threads/:thread_id/archive', body: ['archived'], output: ['thread_id', 'archived'] }, + version: { method: 'GET', path: '/agents/:slug/versions/:version', output: ['version', 'created_at', 'name', 'purpose', 'system_prompt', 'configuration'] }, + restoreVersion: { method: 'POST', path: '/agents/:slug/versions/:version/restore', body: ['expected_revision'], output: draft }, + scheduleContext: { method: 'GET', path: '/agents/:slug/schedule-context', output: ['version', 'enabled', 'configuration', 'result_destination', 'payer'] }, knowledge: { method: 'GET', path: '/agents/:slug/knowledge', output: ['databases', 'models'] }, createKnowledge: { method: 'POST', path: '/agents/:slug/knowledge', body: ['operation_id', 'name', 'embedding_model'], output: ['kb_id', 'name', 'description', 'embedding_model'] }, knowledgeFiles: { method: 'GET', path: '/agents/:slug/knowledge/:kb_id/files', query: { offset: 'offset' }, output: ['files', 'next_offset'] }, @@ -28,10 +51,12 @@ const operations: Record = { keys: { method: 'GET', path: '/applications/:application_id/keys', output: ['keys'] }, createKey: { method: 'POST', path: '/applications/:application_id/keys', body: ['operation_id'], output: ['id', 'prefix', 'secret'] }, revokeKey: { method: 'DELETE', path: '/applications/:application_id/keys/:key_id', output: ['revoked'] }, - costs: { method: 'GET', cloud: 'ws', path: '/costs', query: { slug: 'agent_slug' }, output: ['unit', 'items'] }, - threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset' }, output: ['threads', 'next_offset'] }, + costs: { method: 'GET', cloud: 'ws', path: '/costs', query: { slug: 'agent_slug', offset: 'offset', started_at: 'started_at', ended_at: 'ended_at', source: 'source', application_id: 'application_id' }, output: ['unit', 'items', 'next_offset', 'summary'] }, + budget: { method: 'GET', cloud: 'ws', path: '/agents/:slug/budget', output: budget }, + saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget }, + threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] }, createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] }, - history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'next_offset'] }, + history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] }, viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] }, submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request }, preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request }, @@ -59,6 +84,7 @@ export function operationPlan(value: unknown) { || !input || typeof input !== 'object' || Array.isArray(input)) throw new Error('invalid_operation'); const spec = operations[operation as keyof CloudAgentOperations]; const args = input as Record; + const routeKeys = [...spec.path.matchAll(/:([a-z_]+)/g)].map(match => match[1]); const path = spec.path.replace(/:([a-z_]+)/g, (_, key: string) => { const id = args[key]; if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(id) @@ -69,12 +95,15 @@ export function operationPlan(value: unknown) { for (const [key, name] of Object.entries(spec.query ?? {})) { const v = args[key]; if (v === undefined) continue; - if (key === 'offset' ? !Number.isSafeInteger(v) || Number(v) < 0 : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : 100)) { + if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' ? !Number.isSafeInteger(v) || Number(v) < 0 : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : 100)) { throw new Error('invalid_query'); } query.set(name, String(v)); } return { + operation, + input: Object.fromEntries([...new Set([...routeKeys, ...(spec.body ?? []), ...Object.keys(spec.query ?? {})])] + .filter(key => args[key] !== undefined).map(key => [key, args[key]])), ...spec, path: (spec.cloud === 'ws' ? '/api/cloud-agents' : '/api/makelore') + path + (query.size ? '?' + query : ''), body: spec.body ? Object.fromEntries(spec.body.filter(key => args[key] !== undefined).map(key => [key, args[key]])) : undefined, project: (result: unknown) => { diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 99b53f7..610005e 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -1,5 +1,7 @@ import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentConfiguration, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft, type CloudUpload } from '../../shared/cloud-agents'; import { operationPlan } from './cloud-agent-operations'; +import { CloudAgentJournal } from './cloud-agent-journal'; +import type { CloudRecent } from '../../shared/cloud-agents'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch'; import { @@ -29,6 +31,10 @@ const MESSAGES: Record = { key_revoked: '此凭据已撤销,请新建凭据', insufficient_balance: '创建者的词元点数不足', request_conflict: '该请求已用于其他输入,请开始新的请求', + thread_archived: '请先恢复已归档的对话再提交任务', + thread_active: '请先处理待回答或审批的任务,并停止运行及排队任务,再归档对话', + knowledge_processing: '知识库有文档正在处理,请完成后再维护', + agent_archived: '请先从归档恢复智能体,再启用', attachment_too_large: '附件最大支持 5 MB', download_failed: '文件保存失败,请重试', }; @@ -75,6 +81,7 @@ function projectDraft(value: unknown): CloudAgentDraft { configuration: configuration(item.configuration), published_version: item.published_version == null ? null : revision(item.published_version), enabled: item.enabled !== false, + archived: item.archived === true, }; } catch { throw new CloudAgentsError(502, 'cloud_service_unavailable'); } } @@ -114,7 +121,8 @@ export class CloudAgentsModule { private activityPolling = false; private activitySeen: Map | null = null; - constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise = proxyAwareFetch) { + constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise = proxyAwareFetch, + private readonly journal = new CloudAgentJournal()) { this.unsubscribe = subscribeWorksSquareSession(() => { if (this.cached && !isCurrentWorksSquareAccountBinding(this.cached.binding)) { this.cached = null; @@ -135,9 +143,12 @@ export class CloudAgentsModule { this.unsubscribe(); } - async list(cursor: string | null = null): Promise { + async list(cursor: string | null = null, archived = false): Promise { if (cursor !== null && !/^\d{1,16}$/.test(cursor)) throw new CloudAgentsError(422, 'invalid_input'); - const body = record(await this.request('/api/makelore/agents' + (cursor ? `?cursor=${cursor}` : ''), 'GET')); + const params = new URLSearchParams(); + if (cursor) params.set('cursor', cursor); + if (archived) params.set('archived', 'true'); + const body = record(await this.request('/api/makelore/agents' + (params.size ? '?' + params : ''), 'GET')); if (!Array.isArray(body.agents) || body.agents.length > 100 || (body.next_cursor !== null && (typeof body.next_cursor !== 'string' || !/^\d{1,16}$/.test(body.next_cursor)))) { throw new CloudAgentsError(502, 'cloud_service_unavailable'); @@ -160,7 +171,58 @@ export class CloudAgentsModule { name: textField(input.name, 100).trim(), purpose: textField(input.purpose, 2000).trim(), }; - return projectDraft(await this.request('/api/makelore/agents', 'POST', body)); + return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(projectDraft)); + } + + private async withRecovery(operation: string, input: Record, execute: () => Promise): Promise { + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', input.request_id ?? input.operation_id].join(':'); + await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); + this.requireCurrent(binding); + const result = await execute(); + this.requireCurrent(binding); + await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); + return result; + } + + async recovery() { + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const state = await this.journal.read(binding.accountKey); + this.requireCurrent(binding); + return state; + } + + async rememberRecent(value: unknown) { + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const input = record(value); + const recent: CloudRecent | null = value === null ? null : { + slug: slug(textField(input.slug, 64)), + mode: input.mode === 'preview' ? 'preview' : 'published', + ...(input.thread_id === undefined ? {} : { thread_id: textField(input.thread_id, 128) }), + ...(input.draft_revision === undefined ? {} : { draft_revision: revision(input.draft_revision) }), + }; + await this.journal.remember(binding.accountKey, recent, () => this.requireCurrent(binding)); + return { saved: true }; + } + + async resolvePending(value: unknown) { + const input = record(value); + const id = textField(input.id, 300); + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const state = await this.recovery(); + const pending = state.pending.find(item => item.id === id); + if (!pending) throw new CloudAgentsError(404, 'invalid_input'); + if (input.discard === true) { + await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); + return { discarded: true }; + } + const result = pending.operation === 'createAgent' ? await this.create(pending.input) + : await this.execute({ operation: pending.operation, input: pending.input }); + return { operation: pending.operation, input: pending.input, result }; } async save(agentSlug: string, value: unknown): Promise { @@ -213,7 +275,9 @@ export class CloudAgentsModule { let plan; try { plan = operationPlan(value); } catch { throw new CloudAgentsError(422, 'invalid_input'); } - const result = await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'); + const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws')); + const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild']; + const result = await (recoverable.includes(plan.operation) ? this.withRecovery(plan.operation, plan.input, invoke) : invoke()); try { const projected = plan.project(result); if (record(value).operation === 'access') { @@ -248,14 +312,23 @@ export class CloudAgentsModule { const input = record(value); const slug = textField(input.slug, 128), kbId = textField(input.kb_id, 128); const operationId = textField(input.operation_id, 64); + const replacement = input.replaces_file_id === undefined ? '' : textField(input.replaces_file_id, 64); + if (replacement && !/^[a-zA-Z0-9_-]+$/.test(replacement)) throw new CloudAgentsError(422, 'invalid_input'); if (!/^ml-[a-f0-9]{32}$/.test(slug) || !/^kb_[a-z0-9]+$/.test(kbId) || !/^[a-f0-9-]{36}$/.test(operationId)) throw new CloudAgentsError(422, 'invalid_input'); const body = await this.pickUpload(); if (!body) return null; - const result = record(await this.request(`/api/makelore/agents/${slug}/knowledge/${kbId}/files?operation_id=${operationId}`, 'POST', body)); + const result = record(await this.request(`/api/makelore/agents/${slug}/knowledge/${kbId}/files?operation_id=${operationId}` + (replacement ? '&replaces_file_id=' + replacement : ''), 'POST', body)); return Object.fromEntries(['file_id', 'name', 'size', 'status', 'error', 'chunk_count'].map(key => [key, result[key]])); } + async uploadSkill(): Promise { + const body = await this.pickUpload(); + if (!body) return null; + const result = record(await this.request('/api/makelore/resources/skills/upload', 'POST', body)); + return { draft_id: result.draft_id, items: result.items }; + } + private async pickUpload(): Promise { const binding = getWorksSquareAccountBinding(); if (!binding) throw new CloudAgentsError(401, 'session_expired'); diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index 1286730..1ba4a3c 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -10,6 +10,7 @@ export interface CloudAgentDraft { configuration: CloudAgentConfiguration; published_version: number | null; enabled: boolean; + archived?: boolean; } export interface CloudAgentPage { @@ -73,7 +74,9 @@ export interface CloudThread { thread_id: string; client_thread_id: string | null; agent_slug: string; title: string; mode: 'published' | 'preview'; updated_at: string; run_id: string | null; status: string; unread: boolean; draft_revision?: number; + archived?: boolean; } +export interface CloudVersion { version: number; created_at: string; name: string; purpose: string; system_prompt: string; configuration: CloudAgentConfiguration } export interface CloudRun { agent_run_id: string; request_id: string; thread_id: string; agent_slug: string; status: string; output: string; version: string; @@ -90,7 +93,11 @@ export interface CloudMessage { } export interface CloudHistory { thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null; + queued_requests?: CloudRequest[]; + schedule_proposals?: CloudScheduleProposal[]; } +export interface CloudScheduleProposal { id: string; name: string; prompt: string; cron_expression: string; timezone: string } +export interface CloudScheduleContext { version: number | null; enabled: boolean; configuration: CloudAgentConfiguration | null; result_destination: string; payer: string } export interface CloudRequest { request_id: string; thread_id: string; run_id: string | null; status: string; version: string; } @@ -98,6 +105,19 @@ export interface CloudPrompt { request_id: string; thread_id: string; query: string; expected_revision?: number; attachment_file_ids?: string[]; } +export interface CloudRecent { + slug: string; + thread_id?: string; + mode: 'published' | 'preview'; + draft_revision?: number; +} +export interface CloudPendingOperation { + id: string; operation: string; input: Record; created_at: string; +} +export interface CloudRecoveryState { + recent: CloudRecent | null; + pending: CloudPendingOperation[]; +} export interface CloudScheduleInput { name: string; prompt: string; cron_expression: string; timezone: string; enabled: boolean; } @@ -107,13 +127,21 @@ export interface CloudSchedule extends CloudScheduleInput { } export interface CloudCost { id: string; status: string; reserved_points: string; actual_points: string | null; created_at: string; - context: { agent_slug: string; version: string; caller_kind: string; caller_id: string; run_id: string; request_id: string }; + context: { agent_slug: string; version: string; caller_kind: string; caller_id: string; creator_id?: string; run_id: string; request_id: string; source?: CloudCostSource | null }; } +export type CloudCostSource = 'self' | 'shared' | 'api' | 'scheduled' | 'preview' | 'knowledge'; +export interface CloudCostPage { unit: string; items: CloudCost[]; next_offset: number | null; summary: { count: number; settled_points: string; pending_points: string } } +export interface CloudBudget { agent_slug: string; unit: string; timezone: string; request_limit_points: string | null; daily_limit_points: string | null; daily_committed_points: string; resets_at: string } export interface CloudAttachment { file_id: string; file_name: string; file_size: number; path: string; original_path: string; request_id?: string | null } export interface CloudUpload { object_name: string; file_name: string; file_type: string; parse_supported: boolean; parse_methods: string[] } export interface CloudFile { name: string; path: string; directory_path: string; is_dir: boolean; size: number } export interface CloudKnowledge { kb_id: string; name: string; description: string; embedding_model: string } export interface CloudKnowledgeFile { file_id: string; name: string; size: number; status: string; error: string | null; chunk_count: number } +export interface CloudMcp { slug: string; name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; enabled: boolean; has_credentials: boolean } +export interface CloudMcpInput { name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; headers?: Record | null } +export interface CloudChild { slug: string; name: string; purpose: string; system_prompt: string } +export interface CloudSkillDraft { draft_id: string; items: { slug: string; name: string; description: string; success?: boolean; error?: string }[] } +export interface CloudPersonalResources { mcps: CloudMcp[]; skills: { slug: string; name: string; description: string }[]; subagents: CloudChild[] } type Op = { input: Input; output: Output }; type Agent = { slug: string }; type Application = { application_id: string }; @@ -122,6 +150,24 @@ type Thread = { thread_id: string }; type Run = { run_id: string }; type Operation = { operation_id: string }; export interface CloudAgentOperations { + applicationCalls: Op; + resources: Op, CloudPersonalResources>; + createMcp: Op; + updateMcp: Op; + enableMcp: Op<{ key: string; enabled: boolean }, CloudMcp>; + deleteMcp: Op<{ key: string }, { deleted: boolean }>; + createChild: Op & Operation, CloudChild>; + updateChild: Op & { key: string }, CloudChild>; + confirmSkill: Op<{ draft_id: string }, { items: { slug: string; success: boolean; error?: string }[] }>; + deleteSkill: Op<{ key: string }, { deleted: boolean }>; + deleteKnowledge: Op; + deleteKnowledgeFile: Op; + importKnowledgeAttachment: Op; + archiveAgent: Op; + archiveThread: Op; + version: Op; + restoreVersion: Op; + scheduleContext: Op; knowledge: Op; createKnowledge: Op; knowledgeFiles: Op; @@ -139,8 +185,10 @@ export interface CloudAgentOperations { keys: Op; createKey: Op; revokeKey: Op; - costs: Op<{ slug?: string }, { unit: string; items: CloudCost[] }>; - threads: Op<{ slug?: string; offset?: number }, { threads: CloudThread[]; next_offset: number | null }>; + costs: Op<{ slug?: string; offset?: number; started_at?: string; ended_at?: string; source?: CloudCostSource; application_id?: string }, CloudCostPage>; + budget: Op; + saveBudget: Op; + threads: Op<{ slug?: string; offset?: number; archived?: boolean }, { threads: CloudThread[]; next_offset: number | null }>; createThread: Op; history: Op; viewed: Op; diff --git a/src/lib/cloud-agents-api.ts b/src/lib/cloud-agents-api.ts index 79b85ec..3b608da 100644 --- a/src/lib/cloud-agents-api.ts +++ b/src/lib/cloud-agents-api.ts @@ -1,11 +1,18 @@ import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api'; -import type { CloudAgentOperations, CloudUpload, CloudKnowledgeFile } from '../../shared/cloud-agents'; +import type { CloudAgentOperations, CloudUpload, CloudKnowledgeFile, CloudRecoveryState, CloudRecent, CloudSkillDraft } from '../../shared/cloud-agents'; import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents'; export const cloudAgentsApi = { - uploadKnowledge: (slug: string, kb_id: string, operation_id: string) => + recovery: () => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery'), + remember: (recent: CloudRecent | null) => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery/recent', { + method: 'PUT', body: JSON.stringify(recent), + }), + resolvePending: (id: string, discard = false) => hostApiFetch<{ result?: unknown; operation?: string; input?: Record; discarded?: boolean }>( + CLOUD_AGENTS_PATH + '/recovery/pending', { method: 'POST', body: JSON.stringify({ id, discard }) }), + uploadSkill: () => hostApiFetch(CLOUD_AGENTS_PATH + '/skills/pick', { method: 'POST' }), + uploadKnowledge: (slug: string, kb_id: string, operation_id: string, replaces_file_id?: string) => hostApiFetch(CLOUD_AGENTS_PATH + '/knowledge/pick', { - method: 'POST', body: JSON.stringify({ slug, kb_id, operation_id }), + method: 'POST', body: JSON.stringify({ slug, kb_id, operation_id, replaces_file_id }), }), upload: () => hostApiFetch(CLOUD_AGENTS_PATH + '/attachments/pick', { method: 'POST' }), download: (thread_id: string, path: string) => hostApiFetch<{ saved: boolean }>(CLOUD_AGENTS_PATH + '/files/save', { @@ -19,8 +26,8 @@ export const cloudAgentsApi = { await ensureHostApiToken(); return createHostEventSource(CLOUD_AGENTS_PATH + '/runs/' + encodeURIComponent(runId) + '/events?after_seq=' + encodeURIComponent(after)); }, - list: (cursor: string | null = null) => hostApiFetch( - CLOUD_AGENTS_PATH + '/agents' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''), + list: (cursor: string | null = null, archived = false) => hostApiFetch( + CLOUD_AGENTS_PATH + '/agents' + ((cursor || archived) ? '?' + new URLSearchParams({ ...(cursor ? { cursor } : {}), ...(archived ? { archived: 'true' } : {}) }) : ''), ), get: (slug: string) => hostApiFetch(`${CLOUD_AGENTS_PATH}/agents/${encodeURIComponent(slug)}`), create: (input: CreateCloudAgent) => hostApiFetch(CLOUD_AGENTS_PATH + '/agents', { diff --git a/src/pages/CloudAgents/CloudAccess.tsx b/src/pages/CloudAgents/CloudAccess.tsx index f2b8d09..bdb6ef3 100644 --- a/src/pages/CloudAgents/CloudAccess.tsx +++ b/src/pages/CloudAgents/CloudAccess.tsx @@ -4,12 +4,16 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { usePendingCloudInput } from './CloudPending'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import type { CloudAccess as Access, CloudApplication, CloudCost, CloudKey } from '../../../shared/cloud-agents'; +import type { CloudAccess as Access, CloudApplication, CloudKey } from '../../../shared/cloud-agents'; +import { CloudBudgetEditor, CloudCosts } from './CloudCosts'; +import { CloudLifecycle } from './CloudLifecycle'; +import type { CloudAgentDraft } from '../../../shared/cloud-agents'; +import type { CloudAgentOperations } from '../../../shared/cloud-agents'; +import { cloudStatus } from './CloudChat'; const errorText = (e: unknown) => e instanceof Error ? e.message : '操作失败,请重试'; -export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string; revision: number; onPublished: () => void }) { +export function CloudAccessPanel({ slug, revision, onPublished, onChanged }: { slug: string; revision: number; onPublished: () => void; onChanged?: (draft: CloudAgentDraft) => void }) { const [access, setAccess] = useState(null); - const [costs, setCosts] = useState([]); const [query, setQuery] = useState(''); const [users, setUsers] = useState<{ account_id: string; display_name: string; username: string }[]>([]); const [name, setName] = useState(''); @@ -21,12 +25,9 @@ export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string const applicationOperation = useRef<{ operation_id: string; name: string } | null>(null); const alive = useRef(true); const refresh = useCallback(async () => { - const results = await Promise.allSettled([cloudAgentsApi.call('access', { slug }), cloudAgentsApi.call('costs', { slug })]); + const result = await cloudAgentsApi.call('access', { slug }); if (!alive.current) return; - if (results[0].status === 'fulfilled') setAccess(results[0].value); - if (results[1].status === 'fulfilled') setCosts(results[1].value.items); - const failure = results.find(result => result.status === 'rejected'); - if (failure?.status === 'rejected') throw failure.reason; + setAccess(result); }, [slug]); useEffect(() => { alive.current = true; @@ -98,15 +99,9 @@ export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string

相同请求标识与输入可重试。通过返回的 request_id 查询排队状态,通过 run_id 读取运行结果或订阅事件;这些请求继续使用同一应用凭据。

} -

最近费用

-

最近 100 次模型调用,仅显示费用归属。调用者的对话与文件不会在这里展示。

- {!costs.length ?

还没有模型调用费用

:
- - {costs.map(cost => - - )} -
时间调用来源状态实际点数
{new Date(cost.created_at).toLocaleString()}

{cost.context.caller_kind === 'application' ? '应用' : '个人账号'} · v{cost.context.version}

{cost.context.caller_id}

{({ reserved: '已预占', dispatched: '待结算', pending_review: '待核对用量', settled: '已结算', released: '已释放', failed: '失败' } as Record)[cost.status] ?? cost.status}{cost.actual_points === null ? '待结算' : cost.actual_points}
} -
+ + + v.version) ?? []} onChanged={draft => { onChanged?.(draft); void refresh().catch(e => setError(errorText(e))); }} /> {access &&
发布历史({access.versions.length}) {access.versions.map(v =>

版本 {v.version} · 草稿修订 {v.draft_revision} · {new Date(v.created_at).toLocaleString()}

)}
} ; @@ -140,5 +135,28 @@ function ApplicationKeys({ application, onUpdated }: { application: CloudApplica
} {error &&

{error}

} + ; } + +function ApplicationCalls({ applicationId }: { applicationId: string }) { + const [page, setPage] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const read = async (offset = 0) => { + setBusy(true); setError(''); + try { const result = await cloudAgentsApi.call('applicationCalls', { application_id: applicationId, offset }); + setPage(previous => ({ ...result, items: offset && previous ? [...previous.items, ...result.items] : result.items })); } + catch (e) { setError(errorText(e)); } finally { setBusy(false); } + }; + return
{ if (e.currentTarget.open && !page) void read(); }}> + 应用调用状态 +

包括尚未产生模型费用的请求。只显示状态与版本,不展示应用的会话内容。

+ {error &&

{error}

} + {page && !page.items.length &&

此应用还没有调用记录。

} + {page?.items.map(item =>

{new Date(item.created_at).toLocaleString()} · v{item.version} · {cloudStatus(item.run_status ?? item.request_status)}

+

请求 {item.request_id}{item.run_id && ' · 运行 ' + item.run_id}

)} +
+ {page?.next_offset != null && }
+
; +} diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 1ab6a92..87a6863 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -3,7 +3,7 @@ import { MessageSquarePlus, Send, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread } from '../../../shared/cloud-agents'; +import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents'; import { CloudFiles } from './CloudFiles'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -18,12 +18,13 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching', const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作'; const object = (v: unknown): Record => v && typeof v === 'object' ? v as Record : {}; -export function CloudChat({ slug, previewRevision, initialThread }: { slug: string; previewRevision?: number; initialThread?: CloudThread }) { +export function CloudChat({ slug, previewRevision, initialThread, initialThreadId, onSchedule }: { slug: string; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) { const [threads, setThreads] = useState([]); const [thread, setThread] = useState(initialThread ?? null); const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID()); const [history, setHistory] = useState(null); const [request, setRequest] = useState(null); + const [queued, setQueued] = useState([]); const [run, setRun] = useState(null); const [query, setQuery] = useState(''); const [intent, setIntent] = useState(null); @@ -40,6 +41,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri const generation = useRef(0); const alive = useRef(true); const bottom = useRef(null); + const entry = useRef({ initialThread, initialThreadId }); const mode = previewRevision === undefined ? 'published' : 'preview'; const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => { let page = await cloudAgentsApi.call('history', { thread_id: id }); @@ -49,8 +51,10 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri } if (!alive.current || expectedGeneration !== generation.current) return; setHistory(page); setRun(page.run); setInterrupt(page.run?.interrupt ?? null); + setQueued(page.queued_requests ?? []); + setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null); if (page.run) setThreads(current => current.map(item => item.thread_id === id - ? { ...item, run_id: page.run!.agent_run_id, status: page.run!.status, unread: false } : item)); + ? { ...item, run_id: page.run!.agent_run_id, status: !active(page.run!.status) && page.queued_requests?.length ? 'queued' : page.run!.status, unread: false } : item)); if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined); }, []); useEffect(() => { @@ -58,19 +62,31 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri const g = ++generation.current; const open = async () => { try { - if (previewRevision !== undefined) { - if (initialThread) await readHistory(initialThread.thread_id, g); - return; - } + const { recent } = await cloudAgentsApi.recovery(); let page = await cloudAgentsApi.call('threads', { slug }); while (page.next_offset !== null) { const more = await cloudAgentsApi.call('threads', { slug, offset: page.next_offset }); page = { ...more, threads: [...page.threads, ...more.threads] }; } if (!alive.current || g !== generation.current) return; - const items = page.threads.filter(t => t.mode === mode); + const items = page.threads.filter(t => t.mode === mode && (mode !== 'preview' || t.draft_revision === previewRevision)); setThreads(items); - const latest = initialThread ?? items[0]; + const pointer = recent?.slug === slug && recent.mode === mode + && (mode !== 'preview' || recent.draft_revision === previewRevision) ? recent : null; + const wanted = entry.current.initialThreadId ?? pointer?.thread_id; + let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items[0]); + if (wanted && !latest) { + let archived = await cloudAgentsApi.call('threads', { slug, archived: true }); + for (;;) { + latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode + && (mode !== 'preview' || t.draft_revision === previewRevision)); + if (latest || archived.next_offset === null) break; + archived = await cloudAgentsApi.call('threads', { slug, archived: true, offset: archived.next_offset }); + } + if (!alive.current || g !== generation.current) return; + if (latest) setThreads([...items, latest]); + } + if (wanted && !latest) setError('上次对话已不可用,可选择其他对话或开始新对话'); if (latest) { setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id); await readHistory(latest.thread_id, g); @@ -80,7 +96,15 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri }; void open(); return () => { alive.current = false; generation.current++; }; - }, [slug, mode, previewRevision, initialThread, readHistory]); + }, [slug, mode, previewRevision, readHistory]); + + useEffect(() => { + if (loading) return; + let live = true; + void cloudAgentsApi.remember({ slug, mode, thread_id: thread?.thread_id, draft_revision: previewRevision }) + .catch(e => { if (live) setError(message(e)); }); + return () => { live = false; }; + }, [slug, mode, thread?.thread_id, previewRevision, loading]); useEffect(() => { if (!request || request.run_id || !active(request.status)) return; @@ -102,8 +126,19 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri return () => { live = false; window.clearInterval(timer); }; }, [request]); - const runId = run?.agent_run_id ?? request?.run_id; - const running = active(run?.status ?? request?.status); + useEffect(() => { + if (!queued.length || !thread) return; + const g = generation.current; + const timer = window.setInterval(() => { + void readHistory(thread.thread_id, g).catch(e => { + if (alive.current && generation.current === g) setError(message(e)); + }); + }, 2000); + return () => window.clearInterval(timer); + }, [queued.length, thread, readHistory]); + + const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id; + const running = active(run?.status) || active(request?.status); useEffect(() => { if (!runId || !running) return; let live = true; @@ -158,7 +193,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]); }; const send = async () => { - if (busy || running || (!intent && !query.trim())) return; + if (busy || running || thread?.archived || (!intent && !query.trim())) return; const input = intent ?? { request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(), attachment_file_ids: attachmentIds, @@ -179,7 +214,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri const choose = async (next: CloudThread | null) => { const g = ++generation.current; setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null); - setRequest(null); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery(''); + setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery(''); setAttachmentIds([]); if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } } }; @@ -212,15 +247,39 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri }}>{item.content} )} {liveText &&
{liveText}
} + {onSchedule && history?.schedule_proposals?.map(proposal =>
+

自动任务建议 · {proposal.name}

{proposal.prompt}

+

{proposal.cron_expression} · {proposal.timezone} · 尚未启用

+ +
)} {run?.error && run.status === 'failed' &&

{run.error.message}

} - {interrupt && runId && { + {interrupt && runId && !thread?.archived && { setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null); }} />}
+ {queued.length > 0 &&
+

还有 {queued.length} 条请求排队中,关闭客户端后仍会继续。

+ {queued.map((item, index) =>
+ 排队 {index + 1} · 版本 {item.version} + +
)} +
} {error &&

{error}

} + {thread?.archived &&
+ 此对话已归档,恢复后可继续发送消息。 + +
} { + onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading || Boolean(thread?.archived)} ensureThread={async () => { if (thread) return thread.thread_id; const created = await cloudAgentsApi.call('createThread', { slug, thread_id: clientId, preview: previewRevision !== undefined, expected_revision: previewRevision }); rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode, @@ -231,11 +290,11 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri }
{ e.preventDefault(); void send(); }}>