diff --git a/.gitignore b/.gitignore index dadd1eba9..edd29f9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,8 @@ docs/prd/* !docs/prd/tools/*.md # Internal roadmap&gongzonghao (private) -docs/roadmap/ +docs/roadmap/* +!docs/roadmap/lime-service-skill-cloud-config-prd.md docs/gongzonghao/ docs/bussniss/ docs/oem/ @@ -81,4 +82,4 @@ target-codex* tmp/ -governance/ \ No newline at end of file +governance/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ebfebc2d8..219bd29b0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -5,6 +5,7 @@ - **Agent 线程可靠性面板上线**:会话线程新增 pending request、active incident、最近 outcome 与排队 turn 的统一读模型,前端可直接展示“卡在哪里、为什么卡、下一步怎么恢复” - **恢复动作从诊断面板直达执行**:支持在线中断当前 turn、恢复线程、重放待处理请求、提升排队 turn,减少遇到等待确认、工具失败或队列阻塞时的手工排障成本 - **Agent 工作区继续收口**:`AgentChatWorkspace` 大体量逻辑拆分到共享 contract、命令解析/执行层与多个面板组件,消息列表、决策面板、提及面板、队列面板与工作台布局的职责更清晰 +- **发布构建链路补齐稳定性修复**:补齐 Agent 工作区与线程可靠性面板的 TypeScript 合同边界,修正中断态诊断优先级,`npm run build` / `npm run lint` 与 Rust 测试链路恢复可用 - **Codex slash command 基础接入**:新增 `/compact`、`/clear`、`/new`、`/review`、`/init`、`/diff`、`/status`、`/model`、`/help` 等命令目录与格式化/执行链路,为后续命令扩展提供统一入口 - **项目热力图与治理图再生成能力补齐**:新增仓库热力图脚本、治理图谱脚本和操作文档,可直接生成热点模块与治理候选报告,方便后续收口 legacy/compat 路径 @@ -21,10 +22,11 @@ ### 🧪 测试 +- 发布前执行:`npm run build` +- 发布前执行:`npm run lint` - 发布前执行:`cargo fmt --all --manifest-path src-tauri/Cargo.toml` - 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` - 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`npm run lint` ### 📝 文档 diff --git a/docs/README.md b/docs/README.md index 669bc4dee..168f66c75 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ - `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2) - `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复) - `roadmap/lime-skills-standardization-roadmap.md`:Skills 标准化与产品化路线图 +- `roadmap/lime-service-skill-cloud-config-prd.md`:服务型技能的端优先执行与云配置同步 PRD - `ops.md`:运维与发布说明 - `app.config.ts` / `nuxt.config.ts` / `package.json`:文档站配置 diff --git a/docs/roadmap/lime-service-skill-cloud-config-prd.md b/docs/roadmap/lime-service-skill-cloud-config-prd.md new file mode 100644 index 000000000..4c52f8ae8 --- /dev/null +++ b/docs/roadmap/lime-service-skill-cloud-config-prd.md @@ -0,0 +1,551 @@ +# Lime 服务型技能:端优先执行、云配置同步 PRD + +> 状态:提案 +> 更新时间:2026-03-24 +> 目标:吸收 Ribbi 式服务型技能入口的产品方法,但保持 Lime 的端优先执行与低云成本架构 + +## 1. 文档依据 + +本文不是从抽象产品概念反推,而是基于当前 `lime` 与 `limecore` 两个仓库的现役事实源编写。 + +`lime` 侧主要事实源: + +- `src/components/agent/chat/AgentChatHomeShell.tsx` +- `src/components/agent/chat/components/EmptyState.tsx` +- `src-tauri/src/services/claw_solution_service.rs` +- `src-tauri/src/skills/README.md` +- `src-tauri/src/services/automation_service/mod.rs` +- `src-tauri/src/services/execution_tracker_service.rs` + +`limecore` 侧主要事实源: + +- `docs/api/lime-client-integration.md` +- `services/control-plane-svc/internal/model/client_bootstrap.go` +- `services/scene-orchestrator-svc/README.md` +- `services/control-plane-svc/internal/model/commercial.go` + +关键环境事实: + +1. `lime` 已有首页壳、技能执行链、自动化调度链、执行追踪链。 +2. `limecore` 已有 `control-plane-svc / gateway-svc / scene-orchestrator-svc`,并已有客户端 bootstrap 聚合接口。 +3. `bootstrap.serviceCatalog` 现有语义偏商业服务目录,不适合直接承载新的服务型技能目录。 +4. `scene-orchestrator-svc` 已定位为 Scene 运行时服务,但不应在本方案中演进为默认主执行流。 + +## 2. 背景与问题 + +Ribbi 这类产品最值得借鉴的,不是“49 个技能入口”或“看起来像多 Agent”,而是它把底层复杂能力包装成了用户能直接理解的服务项: + +- 复制短视频 +- 每日趋势摘要 +- 跟踪账号表现 + +这种产品方式对 Lime 有吸引力,但如果简单把所有入口、参数、执行器和 OEM 配置都堆进客户端,会出现三个问题: + +1. 产品入口层、执行层、配置层混在一起,客户端越来越杂乱。 +2. OEM/租户差异化无法优雅下发,只能写死在本地版本里。 +3. 如果为了统一而把执行迁到云端,又会把云成本做重,违背 Lime 想利用客户端算力的目标。 + +因此,本方案要解决的核心矛盾是: + +**既要获得服务型技能的产品入口优势,又不能把主执行流云化。** + +## 3. 核心判断 + +本方案采用如下主张: + +**端优先执行,云端优先配置。** + +具体含义: + +1. `lime` 客户端负责即时执行、本地工作区、本地调度、本地素材与项目上下文、本地执行追踪。 +2. `limecore` 负责 OEM/租户级目录发布、开关、排序、默认参数、版本、连接器引用,以及少量必须云托管的特例场景。 +3. 默认所有服务型技能都先尝试在客户端执行,只有显式标记为 `cloud_required` 的场景才允许走云执行。 +4. 服务型技能目录采用 **云主本辅**: + - 云目录是产品默认事实源 + - 本地技能与项目级技能继续存在,但不污染 OEM 正式目录 + +一句话总结: + +**Lime 继续是工作站,LimeCore 负责目录、策略与发布,而不是默认运行时。** + +## 4. 目标与非目标 + +## 4.1 总目标 + +为 Lime 增加一层 `ServiceSkill` 产品对象,让用户以“我要完成什么任务”作为入口,而不是先理解模型、Provider 和能力开关。 + +目标链路: + +`服务型技能入口 -> 参数采集 -> 执行路由 -> 本地执行 / 云特例执行 -> 结果与追踪` + +## 4.2 子目标 + +1. 首页优先展示云下发的服务型技能目录。 +2. 参数采集走结构化 `slotSchema`,不再单靠 prompt 模板。 +3. 默认执行继续落在客户端 `skill / agent / browser / automation`。 +4. 离线时仍可使用缓存目录与本地执行能力。 +5. 为 OEM/租户提供目录与策略配置能力,但不引入重云执行成本。 + +## 4.3 非目标 + +1. 不重写 `AgentChatWorkspace`。 +2. 不把 `scene-orchestrator-svc` 变成普通任务的默认执行器。 +3. 不在本阶段做云端大规模内容生成或视频处理。 +4. 不在本阶段做本地技能反向发布云端。 +5. 不在本阶段做完整运营后台编辑器。 + +## 5. 术语与对象模型 + +## 5.1 ServiceSkill + +`ServiceSkill` 是面向用户的产品入口对象,不等同于现有 Agent Skills 标准包。 + +建议最小字段如下: + +| 字段 | 说明 | +|------|------| +| `id` | 服务型技能唯一标识 | +| `title` | 用户可理解的名称 | +| `summary` | 服务收益描述 | +| `category` | 分类,如社媒、视频、图文、运营 | +| `slotSchema` | 参数槽位定义 | +| `runnerType` | `instant / scheduled / managed` | +| `defaultExecutorBinding` | `native_skill / agent_turn / browser_assist / automation_job / cloud_scene` | +| `executionLocation` | `client_default / cloud_required` | +| `readinessRequirements` | 模型、浏览器、技能、项目、账号等依赖 | +| `version` | 目录版本或服务项版本 | +| `source` | `cloud_catalog / local_custom` | + +## 5.2 ServiceSkillCatalog + +新的客户端目录字段命名为: + +`serviceSkillCatalog` + +而不是复用现有 `bootstrap.serviceCatalog`。 + +原因: + +- 现有 `serviceCatalog` 在 `limecore` 中偏商业服务目录与计费目录 +- 新目录是客户端任务入口目录,语义不同 +- 强行复用会让商业服务和服务型技能混成一层 + +建议结构: + +| 字段 | 说明 | +|------|------| +| `version` | 目录版本 | +| `tenantId` | 所属租户 | +| `syncedAt` | 同步时间 | +| `items` | `ServiceSkill[]` | + +## 5.3 SlotSchema + +服务型技能的参数采集协议,首期支持以下类型: + +- `text` +- `url` +- `file` +- `enum` +- `platform` +- `region` +- `schedule_time` +- `account_list` + +每个槽位至少包含: + +- `key` +- `label` +- `type` +- `required` +- `placeholder` +- `defaultValue` +- `helpText` + +## 5.4 RunnerType 与 ExecutionLocation + +`runnerType` 表示任务形态: + +- `instant`:一次性交付 +- `scheduled`:定时运行 +- `managed`:持续跟踪或持续托管 + +`executionLocation` 表示默认执行位置: + +- `client_default`:默认客户端执行 +- `cloud_required`:必须云执行 + +约束: + +- 不是所有 `managed` 都必须上云 +- 不是所有 `scheduled` 都必须上云 +- 只有显式声明 `cloud_required` 的场景才允许默认路由到 `scene-orchestrator-svc` + +## 6. 系统架构 + +## 6.1 总体架构图 + +```mermaid +flowchart TB + subgraph UX["用户交互层(Lime 客户端)"] + Home["首页服务型技能入口"] + Slots["参数采集 / Slot Filling"] + Workspace["Workspace / 任务中心"] + end + + subgraph Client["客户端运行层(lime)"] + Cache["serviceSkillCatalog 缓存"] + Router["执行路由器"] + SkillRuntime["Skills / Agent Runtime"] + Browser["Browser Assist"] + Automation["Automation"] + Tracker["ExecutionTracker"] + end + + subgraph CloudControl["云端控制层(limecore)"] + Bootstrap["control-plane-svc\nclient bootstrap"] + SkillCatalog["serviceSkillCatalog\nOEM/租户目录"] + Policy["开关 / 排序 / 默认参数 / 版本"] + end + + subgraph CloudRuntime["云端特例运行层(仅 cloud_required)"] + Scene["scene-orchestrator-svc"] + Gateway["gateway-svc"] + Connector["Hosted Connector / 外部连接器"] + end + + Home --> Slots --> Router + Home --> Cache + Workspace --> Tracker + Bootstrap --> Cache + Policy --> SkillCatalog --> Bootstrap + + Router --> SkillRuntime + Router --> Browser + Router --> Automation + + SkillRuntime --> Tracker + Browser --> Tracker + Automation --> Tracker + Tracker --> Workspace + + Router -. 仅 cloud_required .-> Scene + Scene --> Gateway + Scene --> Connector + Scene -. 返回结果 .-> Workspace +``` + +## 6.2 职责划分 + +### `lime` 客户端负责 + +- 首页入口展示 +- 参数采集 +- 本地即时执行 +- 本地定时与持续任务 +- 本地工作区与结果展示 +- 本地执行追踪 +- 云目录缓存与离线回退 +- 本地自定义技能发现 + +### `limecore control-plane-svc` 负责 + +- `serviceSkillCatalog` 下发 +- OEM/租户级目录配置 +- 开关、排序、默认参数、版本 +- 客户端 bootstrap 聚合 + +### `limecore scene-orchestrator-svc` 负责 + +- 少量 `cloud_required` 场景运行 +- 调用 `native / gateway / hosted connector` +- 输出统一云端运行结果 + +### `limecore gateway-svc` 负责 + +- 网关型模型访问 +- OpenAI 兼容补全接口 +- 不承担服务型技能目录职责 + +## 7. 核心流程 + +## 7.1 目录同步时序图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Lime as Lime 客户端 + participant Cache as 本地目录缓存 + participant CP as control-plane-svc + + User->>Lime: 启动客户端 + Lime->>CP: GET /client/bootstrap + alt 拉取成功 + CP-->>Lime: bootstrap + serviceSkillCatalog + Lime->>Cache: 写入目录缓存(version, syncedAt, items) + Lime-->>User: 首页展示云目录服务项 + else 拉取失败 + Lime->>Cache: 读取最近一次成功目录 + alt 缓存存在 + Cache-->>Lime: 返回缓存目录 + Lime-->>User: 首页展示缓存目录服务项 + else 无缓存 + Lime-->>User: 回退本地技能入口 + end + end +``` + +## 7.2 本地即时执行时序图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Lime as Lime 客户端 + participant Router as 执行路由器 + participant Runtime as Skills/Agent/Browser + participant Tracker as ExecutionTracker + + User->>Lime: 选择 ServiceSkill + Lime-->>User: 展示 slotSchema 参数采集 + User->>Lime: 提交参数 + Lime->>Router: 根据 runnerType + defaultExecutorBinding + executionLocation 决策 + Router->>Runtime: 启动本地执行 + Runtime->>Tracker: 记录 run metadata(service_skill_id 等) + Runtime-->>Lime: 返回结果 / 产物 / 状态 + Tracker-->>Lime: 返回执行摘要 + Lime-->>User: 在 Workspace 展示结果与轨迹 +``` + +## 7.3 云端特例执行时序图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Lime as Lime 客户端 + participant Router as 执行路由器 + participant Scene as scene-orchestrator-svc + participant Gateway as gateway-svc / hosted connector + + User->>Lime: 选择 cloud_required ServiceSkill + Lime-->>User: 展示 slotSchema 参数采集 + User->>Lime: 提交参数 + Lime->>Router: 识别 executionLocation = cloud_required + Router->>Scene: 创建 Scene Run + Scene->>Gateway: 调用 gateway / connector + Gateway-->>Scene: 返回执行结果 + Scene-->>Lime: 返回 Scene Run 结果 + Lime-->>User: 展示云端结果与状态 +``` + +## 7.4 启动与目录选择流程图 + +```mermaid +flowchart TD + A["客户端启动"] --> B{"bootstrap 拉取成功?"} + B -- 是 --> C["读取 serviceSkillCatalog"] + C --> D["写入本地缓存"] + D --> E["首页优先展示云目录服务项"] + B -- 否 --> F{"本地有缓存目录?"} + F -- 是 --> G["读取缓存目录"] + G --> E + F -- 否 --> H["回退本地技能入口"] +``` + +## 7.5 执行决策流程图 + +```mermaid +flowchart TD + A["用户选择 ServiceSkill"] --> B["按 slotSchema 采集参数"] + B --> C{"必填参数齐全?"} + C -- 否 --> D["继续补参"] + D --> B + C -- 是 --> E{"readiness 满足?"} + E -- 否 --> F["提示缺失模型/浏览器/技能/账号"] + E -- 是 --> G{"executionLocation"} + G -- client_default --> H{"runnerType"} + H -- instant --> I["路由到 native_skill / agent_turn / browser_assist"] + H -- scheduled --> J["创建本地 automation job"] + H -- managed --> K["创建本地持续任务 / automation job"] + G -- cloud_required --> L["创建 Scene Run"] + I --> M["进入 ExecutionTracker 与 Workspace"] + J --> N["进入任务中心与本地调度"] + K --> N + L --> O["展示云端运行结果"] +``` + +## 7.6 任务生命周期流程图 + +```mermaid +flowchart LR + A["创建 scheduled / managed 任务"] --> B["待运行"] + B --> C["运行中"] + C --> D{"执行结果"} + D -- 成功 --> E["写入历史结果"] + E --> F["等待下次运行"] + F --> C + D -- 失败 --> G["记录失败原因"] + G --> H{"是否重试 / 下次调度?"} + H -- 是 --> F + H -- 否 --> I["暂停 / 停止"] +``` + +## 8. 产品与接口设计 + +## 8.1 目录事实源 + +采用 **云主本辅**: + +- 云目录:默认首页产品事实源 +- 本地技能:补充来源 +- 项目级技能:本地能力延伸,不写回 OEM 目录 + +展示策略: + +1. 首页优先展示 `source = cloud_catalog` 的服务型技能。 +2. 本地技能放在“本地技能 / 自定义技能”分组。 +3. 不允许用本地同名覆盖云目录入口。 + +## 8.2 客户端 bootstrap 扩展 + +建议在现有 `bootstrap` 中新增: + +```json +{ + "serviceSkillCatalog": { + "version": "2026-03-24-v1", + "tenantId": "tenant-0001", + "syncedAt": "2026-03-24T00:00:00Z", + "items": [] + } +} +``` + +不建议复用现有: + +- `serviceCatalog` +- `sceneCatalog` + +原因: + +- `serviceCatalog` 偏商业计费目录 +- `sceneCatalog` 偏云运行场景目录 +- `ServiceSkill` 是前台产品入口目录,语义独立 + +## 8.3 本地缓存模型 + +客户端缓存最小结构: + +| 字段 | 说明 | +|------|------| +| `tenantId` | 当前租户 | +| `version` | 云目录版本 | +| `syncedAt` | 最近同步时间 | +| `items` | 缓存的 `ServiceSkill[]` | + +缓存规则: + +1. 启动时优先拉取在线目录。 +2. 拉取失败时回退缓存。 +3. 缓存只作为只读目录快照,不承载用户编辑。 + +## 9. 默认执行策略 + +## 9.1 规则 + +默认执行位置如下: + +| runnerType | 默认执行位置 | 默认绑定 | +|------|------|------| +| `instant` | 客户端 | `native_skill / agent_turn / browser_assist` | +| `scheduled` | 客户端 | `automation_job` | +| `managed` | 客户端 | `automation_job` 或本地持续任务模型 | + +只有下列情况才允许 `cloud_required`: + +1. 必须使用受保护的云连接器密钥 +2. 必须跨设备持续在线 +3. 必须依赖租户侧受控外部系统 + +## 9.2 成本原则 + +本方案明确规定: + +1. 云端默认不承担 CPU 密集型内容生产任务。 +2. 云端默认不承担大规模定时创作。 +3. 云端默认不承担普通技能主执行流。 +4. 所有能在客户端执行的任务,默认都应留在客户端。 + +## 10. MVP 范围 + +首期只做 6 个服务型技能入口,覆盖三类 runner。 + +| 服务型技能 | runnerType | executionLocation | 默认执行绑定 | +|------|------|------|------| +| 复制轮播帖 | `instant` | `client_default` | `native_skill` | +| 复制短视频脚本 | `instant` | `client_default` | `agent_turn` | +| 文章转 Slide 视频提纲 | `instant` | `client_default` | `native_skill` | +| 视频配音成其他语言 | `instant` | `client_default` | `agent_turn` | +| 每日趋势摘要 | `scheduled` | `client_default` | `automation_job` | +| 跟踪账号表现 | `managed` | `client_default` | `automation_job` | + +首期不把这些默认迁到云端。 + +## 11. 实施阶段 + +### Phase 1:目录与即时执行 + +- `limecore` 下发 `serviceSkillCatalog` +- `lime` 接收并缓存云目录 +- 首页展示服务型技能卡片 +- 跑通 `instant + client_default` + +### Phase 2:本地任务化 + +- 跑通 `scheduled + managed` +- 增加任务中心中的参数摘要、最近运行、失败原因 +- 增加离线目录与本地技能并存展示 + +### Phase 3:云端特例分支 + +- 为少量 `cloud_required` 场景接入 `scene-orchestrator-svc` +- 增加客户端提示与状态展示 +- 完善 OEM 目录版本与灰度策略 + +## 12. 验收标准 + +满足以下条件视为本 PRD 成立: + +1. 客户端可从 bootstrap 读取独立的 `serviceSkillCatalog`。 +2. 云目录可本地缓存,离线时可回退展示。 +3. 首页可直接启动服务型技能,而不是先选模型和能力开关。 +4. 参数采集由 `slotSchema` 驱动,而不是纯 prompt 注入。 +5. 至少一个 `instant`、一个 `scheduled`、一个 `managed` 场景可闭环。 +6. 普通服务型技能默认留在客户端执行。 +7. 只有显式声明 `cloud_required` 的场景才允许走云执行。 +8. 执行结果仍统一进入现有 `ExecutionTracker` 体系。 + +## 13. 风险与约束 + +1. 客户端职责膨胀风险 + - 如果目录、参数采集、执行、任务中心同时无边界扩张,客户端仍会变重 +2. 术语冲突风险 + - `serviceCatalog / sceneCatalog / serviceSkillCatalog` 若不统一,会让实现和产品都混乱 +3. 本地调度可靠性风险 + - `scheduled / managed` 留在本地会受到应用在线状态影响 +4. 云执行漂移风险 + - 如果实现时偷懒把普通服务项转去 `scene-orchestrator-svc`,成本结构会失控 + +## 14. 结论 + +Ribbi 式产品方式值得借鉴,但 Lime 不应复制其重 SaaS 执行模式。 + +正确路线是: + +**把服务型技能的目录、配置、策略和版本放到 LimeCore;把主要执行留在 Lime 客户端。** + +这能同时满足四个目标: + +1. 前台入口更清晰 +2. OEM 配置可下发 +3. 云成本可控 +4. 客户端算力得到充分利用 diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 06c9398ea..396ff2a70 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -30,6 +30,7 @@ use aster::permission::{Permission, PermissionConfirmation, PrincipalType}; #[cfg(test)] use aster::skills::{global_registry, load_skills_from_directory, SkillSource}; use aster::tools::{create_shared_history, EditTool, WriteTool}; +use chrono::Utc; use futures::StreamExt; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -103,6 +104,13 @@ impl QueuedTurnTask { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct RuntimeInterruptMarker { + pub source: String, + pub reason: String, + pub requested_at: String, +} + /// Provider 配置信息 #[derive(Debug, Clone)] pub struct ProviderConfig { @@ -147,6 +155,8 @@ pub struct AsterAgentState { agent: Arc>>, /// 当前活跃的取消令牌(用于中止正在进行的对话) cancel_tokens: Arc>>, + /// 最近一次显式中断请求(用于 runtime 诊断) + interrupt_markers: Arc>>, /// 当前 Provider 配置 current_provider_config: Arc>>, /// 凭证桥接器 @@ -162,6 +172,7 @@ impl Clone for AsterAgentState { Self { agent: self.agent.clone(), cancel_tokens: self.cancel_tokens.clone(), + interrupt_markers: self.interrupt_markers.clone(), current_provider_config: self.current_provider_config.clone(), credential_bridge: CredentialBridge::new(), initialized_cache: self.initialized_cache.clone(), @@ -182,6 +193,7 @@ impl AsterAgentState { Self { agent: Arc::new(RwLock::new(None)), cancel_tokens: Arc::new(RwLock::new(std::collections::HashMap::new())), + interrupt_markers: Arc::new(RwLock::new(std::collections::HashMap::new())), current_provider_config: Arc::new(RwLock::new(None)), credential_bridge: CredentialBridge::new(), initialized_cache: Arc::new(AtomicBool::new(false)), @@ -489,6 +501,9 @@ impl AsterAgentState { /// 创建新的取消令牌 pub async fn create_cancel_token(&self, session_id: &str) -> CancellationToken { let token = CancellationToken::new(); + let mut markers = self.interrupt_markers.write().await; + markers.remove(session_id); + drop(markers); let mut tokens = self.cancel_tokens.write().await; tokens.insert(session_id.to_string(), token.clone()); token @@ -511,6 +526,49 @@ impl AsterAgentState { tokens.remove(session_id); } + pub async fn record_interrupt_request( + &self, + session_id: &str, + source: &str, + reason: &str, + ) -> Option { + let trimmed_session_id = session_id.trim(); + let trimmed_source = source.trim(); + let trimmed_reason = reason.trim(); + if trimmed_session_id.is_empty() || trimmed_source.is_empty() || trimmed_reason.is_empty() { + return None; + } + + let marker = RuntimeInterruptMarker { + source: trimmed_source.to_string(), + reason: trimmed_reason.to_string(), + requested_at: Utc::now().to_rfc3339(), + }; + let mut markers = self.interrupt_markers.write().await; + markers.insert(trimmed_session_id.to_string(), marker.clone()); + Some(marker) + } + + pub async fn get_interrupt_marker(&self, session_id: &str) -> Option { + let trimmed_session_id = session_id.trim(); + if trimmed_session_id.is_empty() { + return None; + } + + let markers = self.interrupt_markers.read().await; + markers.get(trimmed_session_id).cloned() + } + + pub async fn clear_interrupt_marker(&self, session_id: &str) { + let trimmed_session_id = session_id.trim(); + if trimmed_session_id.is_empty() { + return; + } + + let mut markers = self.interrupt_markers.write().await; + markers.remove(trimmed_session_id); + } + /// 提交用户补充信息,恢复等待中的 ask_user / elicitation。 pub async fn submit_elicitation_response( &self, @@ -726,6 +784,37 @@ mod tests { assert!(!state.cancel_session(session_id).await); } + #[tokio::test] + async fn test_interrupt_marker_lifecycle() { + let state = AsterAgentState::new(); + let session_id = "session-interrupt"; + + let marker = state + .record_interrupt_request(session_id, "user", "用户主动停止当前执行") + .await + .expect("应记录中断标记"); + assert_eq!(marker.source, "user"); + assert_eq!(marker.reason, "用户主动停止当前执行"); + assert_eq!( + state + .get_interrupt_marker(session_id) + .await + .as_ref() + .map(|value| value.reason.as_str()), + Some("用户主动停止当前执行") + ); + + let _token = state.create_cancel_token(session_id).await; + assert!(state.get_interrupt_marker(session_id).await.is_none()); + + state + .record_interrupt_request(session_id, "user", "第二次停止") + .await + .expect("应重新记录中断标记"); + state.clear_interrupt_marker(session_id).await; + assert!(state.get_interrupt_marker(session_id).await.is_none()); + } + #[test] fn test_session_turn_queue_manager_snapshot() { let task = QueuedTurnTask { diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index a1fcae28e..41d5fa605 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -47,7 +47,7 @@ mod write_artifact_events; pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response}; pub use aster_runtime_support::{initialize_aster_runtime, restore_aster_runtime_queued_turns}; -pub use aster_state::{AsterAgentState, ProviderConfig, QueuedTurnTask}; +pub use aster_state::{AsterAgentState, ProviderConfig, QueuedTurnTask, RuntimeInterruptMarker}; pub use aster_state_support::{ build_project_system_prompt, create_lime_identity, create_lime_tool_config, message_helpers, reload_lime_skills, SessionConfigBuilder, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index dbb934af3..00fe9956c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -41,6 +41,11 @@ pub async fn agent_runtime_interrupt_turn( ) -> Result { let session_id = request.session_id; let cancelled = state.cancel_session(&session_id).await; + if cancelled { + let _ = state + .record_interrupt_request(&session_id, "user", "用户主动停止当前执行") + .await; + } let cleared = clear_runtime_queue_service(&app, &session_id).await?; Ok(cancelled || !cleared.is_empty()) } @@ -133,12 +138,14 @@ pub async fn agent_runtime_get_session( let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?; let projection = sync_thread_reliability_projection(db.inner(), &detail)?; + let interrupt_marker = state.get_interrupt_marker(&session_id).await; let thread_read = AgentRuntimeThreadReadModel::from_parts( &detail, &queued_turns, projection.pending_requests, projection.last_outcome, projection.incidents, + interrupt_marker.as_ref(), ); Ok( AgentRuntimeSessionDetail::from_session_detail_with_thread_read( @@ -187,12 +194,14 @@ pub async fn agent_runtime_get_thread_read( let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?; let projection = sync_thread_reliability_projection(db.inner(), &detail)?; + let interrupt_marker = state.get_interrupt_marker(&session_id).await; Ok(AgentRuntimeThreadReadModel::from_parts( &detail, &queued_turns, projection.pending_requests, projection.last_outcome, projection.incidents, + interrupt_marker.as_ref(), )) } diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 351d09ccf..3073943dc 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -298,6 +298,115 @@ pub struct AgentRuntimeIncidentView { pub cleared_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeDiagnosticWarningSample { + pub item_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + pub message: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeDiagnosticContextCompactionSample { + pub item_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub stage: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeDiagnosticFailedToolSample { + pub item_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub tool_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeDiagnosticFailedCommandSample { + pub item_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeDiagnosticPendingRequestSample { + pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub request_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub waited_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadDiagnostics { + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_elapsed_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_stalled_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_error_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interrupt_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_interrupt_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_interrupt_requested_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_interrupt_wait_seconds: Option, + pub warning_count: usize, + pub context_compaction_count: usize, + pub failed_tool_call_count: usize, + pub failed_command_count: usize, + pub pending_request_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub oldest_pending_request_wait_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_blocking_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_blocking_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_warning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_context_compaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_failed_tool: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_failed_command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_pending_request: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentRuntimeThreadReadModel { pub thread_id: String, @@ -316,6 +425,8 @@ pub struct AgentRuntimeThreadReadModel { pub interrupt_state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -443,6 +554,7 @@ impl AgentRuntimeThreadReadModel { pending_requests, last_outcome, incidents, + None, ) } @@ -452,8 +564,18 @@ impl AgentRuntimeThreadReadModel { pending_requests: Vec, last_outcome: Option, incidents: Vec, + runtime_interrupt_marker: Option<&lime_agent::RuntimeInterruptMarker>, ) -> Self { let latest_turn = detail.turns.last(); + let diagnostics = build_thread_diagnostics( + detail, + latest_turn, + queued_turns, + &pending_requests, + last_outcome.as_ref(), + &incidents, + runtime_interrupt_marker, + ); let active_turn = detail .turns .iter() @@ -465,17 +587,20 @@ impl AgentRuntimeThreadReadModel { ) }) .or(latest_turn); - let status = if !pending_requests.is_empty() { - "waiting_request".to_string() - } else if active_turn + let active_turn_running = active_turn .map(|turn| { matches!( turn.status, lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running ) }) - .unwrap_or(false) - { + .unwrap_or(false); + let interrupting = runtime_interrupt_marker.is_some() && active_turn_running; + let status = if interrupting { + "interrupting".to_string() + } else if !pending_requests.is_empty() { + "waiting_request".to_string() + } else if active_turn_running { "running".to_string() } else if let Some(turn) = latest_turn { turn.status.as_str().to_string() @@ -494,6 +619,13 @@ impl AgentRuntimeThreadReadModel { None } }); + let interrupt_state = interrupt_state.or_else(|| { + if interrupting { + Some("interrupting".to_string()) + } else { + None + } + }); Self { thread_id: detail.thread_id.clone(), @@ -507,6 +639,7 @@ impl AgentRuntimeThreadReadModel { updated_at: latest_turn .map(|turn| turn.updated_at.clone()) .or_else(|| Some(detail.updated_at.to_string())), + diagnostics, } } } @@ -526,6 +659,359 @@ fn elapsed_seconds_since(raw: Option<&str>, now: &DateTime) -> Option Some(now.signed_duration_since(parsed).num_seconds().max(0)) } +fn elapsed_seconds_between(start_raw: Option<&str>, end_raw: Option<&str>) -> Option { + let start = parse_rfc3339_utc(start_raw?)?; + let end = parse_rfc3339_utc(end_raw?)?; + Some(end.signed_duration_since(start).num_seconds().max(0)) +} + +fn warning_sample_from_item( + item: &lime_core::database::dao::agent_timeline::AgentThreadItem, +) -> Option { + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Warning { + message, + code, + } => Some(AgentRuntimeDiagnosticWarningSample { + item_id: item.id.clone(), + turn_id: Some(item.turn_id.clone()), + code: code.clone(), + message: message.clone(), + updated_at: item.updated_at.clone(), + }), + _ => None, + } +} + +fn context_compaction_sample_from_item( + item: &lime_core::database::dao::agent_timeline::AgentThreadItem, +) -> Option { + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ContextCompaction { + stage, + trigger, + detail, + } => Some(AgentRuntimeDiagnosticContextCompactionSample { + item_id: item.id.clone(), + turn_id: Some(item.turn_id.clone()), + stage: stage.clone(), + trigger: trigger.clone(), + detail: detail.clone(), + updated_at: item.updated_at.clone(), + }), + _ => None, + } +} + +fn failed_tool_sample_from_item( + item: &lime_core::database::dao::agent_timeline::AgentThreadItem, +) -> Option { + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ToolCall { + tool_name, + error, + success, + .. + } if matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed + ) || success == &Some(false) => + { + Some(AgentRuntimeDiagnosticFailedToolSample { + item_id: item.id.clone(), + turn_id: Some(item.turn_id.clone()), + tool_name: tool_name.clone(), + error: error.clone(), + updated_at: item.updated_at.clone(), + }) + } + _ => None, + } +} + +fn failed_command_sample_from_item( + item: &lime_core::database::dao::agent_timeline::AgentThreadItem, +) -> Option { + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::CommandExecution { + command, + exit_code, + error, + .. + } if matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed + ) || exit_code.unwrap_or(0) != 0 + || error.as_ref().is_some() => + { + Some(AgentRuntimeDiagnosticFailedCommandSample { + item_id: item.id.clone(), + turn_id: Some(item.turn_id.clone()), + command: command.clone(), + exit_code: *exit_code, + error: error.clone(), + updated_at: item.updated_at.clone(), + }) + } + _ => None, + } +} + +fn pending_request_sample_from_view( + request: &AgentRuntimeRequestView, + now: &DateTime, +) -> AgentRuntimeDiagnosticPendingRequestSample { + AgentRuntimeDiagnosticPendingRequestSample { + request_id: request.id.clone(), + turn_id: request.turn_id.clone(), + request_type: request.request_type.clone(), + title: request.title.clone(), + waited_seconds: elapsed_seconds_since(request.created_at.as_deref(), now), + created_at: request.created_at.clone(), + } +} + +fn build_thread_diagnostics( + detail: &SessionDetail, + latest_turn: Option<&lime_core::database::dao::agent_timeline::AgentThreadTurn>, + queued_turns: &[QueuedTurnSnapshot], + pending_requests: &[AgentRuntimeRequestView], + last_outcome: Option<&AgentRuntimeOutcomeView>, + incidents: &[AgentRuntimeIncidentView], + runtime_interrupt_marker: Option<&lime_agent::RuntimeInterruptMarker>, +) -> Option { + let warning_count = detail + .items + .iter() + .filter(|item| warning_sample_from_item(item).is_some()) + .count(); + let context_compaction_count = detail + .items + .iter() + .filter(|item| context_compaction_sample_from_item(item).is_some()) + .count(); + let failed_tool_call_count = detail + .items + .iter() + .filter(|item| failed_tool_sample_from_item(item).is_some()) + .count(); + let failed_command_count = detail + .items + .iter() + .filter(|item| failed_command_sample_from_item(item).is_some()) + .count(); + + let latest_warning = detail.items.iter().rev().find_map(warning_sample_from_item); + let latest_context_compaction = detail + .items + .iter() + .rev() + .find_map(context_compaction_sample_from_item); + let latest_failed_tool = detail + .items + .iter() + .rev() + .find_map(failed_tool_sample_from_item); + let latest_failed_command = detail + .items + .iter() + .rev() + .find_map(failed_command_sample_from_item); + + if latest_turn.is_none() + && warning_count == 0 + && context_compaction_count == 0 + && failed_tool_call_count == 0 + && failed_command_count == 0 + && pending_requests.is_empty() + && queued_turns.is_empty() + { + return None; + } + + let now = Utc::now(); + let latest_pending_request = pending_requests + .first() + .map(|request| pending_request_sample_from_view(request, &now)); + let oldest_pending_request_wait_seconds = pending_requests + .iter() + .filter_map(|request| elapsed_seconds_since(request.created_at.as_deref(), &now)) + .max(); + let latest_turn_status = latest_turn.map(|turn| turn.status.as_str().to_string()); + let latest_turn_started_at = latest_turn.map(|turn| turn.started_at.clone()); + let latest_turn_completed_at = latest_turn.and_then(|turn| turn.completed_at.clone()); + let latest_turn_updated_at = latest_turn.map(|turn| turn.updated_at.clone()); + let latest_turn_elapsed_seconds = latest_turn.and_then(|turn| { + if turn.completed_at.is_some() { + elapsed_seconds_between(Some(turn.started_at.as_str()), turn.completed_at.as_deref()) + } else { + elapsed_seconds_since(Some(turn.started_at.as_str()), &now) + } + }); + let latest_turn_stalled_seconds = latest_turn.and_then(|turn| { + if matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + ) { + elapsed_seconds_since(Some(turn.updated_at.as_str()), &now) + } else { + None + } + }); + let latest_turn_error_message = latest_turn.and_then(|turn| turn.error_message.clone()); + let runtime_interrupt_source = runtime_interrupt_marker.map(|marker| marker.source.clone()); + let runtime_interrupt_requested_at = + runtime_interrupt_marker.map(|marker| marker.requested_at.clone()); + let runtime_interrupt_wait_seconds = runtime_interrupt_marker + .and_then(|marker| elapsed_seconds_since(Some(marker.requested_at.as_str()), &now)); + let interrupt_reason = latest_turn.and_then(|turn| { + if matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted + ) { + runtime_interrupt_marker + .map(|marker| marker.reason.clone()) + .or_else(|| turn.error_message.clone()) + .or_else(|| { + latest_warning + .as_ref() + .map(|sample| sample.message.clone()) + .filter(|message| !message.trim().is_empty()) + }) + } else { + runtime_interrupt_marker.map(|marker| marker.reason.clone()) + } + }); + let latest_turn_running = latest_turn + .map(|turn| { + matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + ) + }) + .unwrap_or(false); + let latest_turn_aborted = latest_turn + .map(|turn| { + matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted + ) + }) + .unwrap_or(false); + let primary_blocking_kind = if runtime_interrupt_marker.is_some() && latest_turn_running { + Some("interrupting".to_string()) + } else if let Some(request) = pending_requests.first() { + if is_tool_confirmation_request(&request.request_type) { + Some("waiting_approval".to_string()) + } else if is_user_input_request(&request.request_type) { + Some("waiting_user_input".to_string()) + } else { + Some("waiting_request".to_string()) + } + } else if latest_turn_aborted { + Some("interrupted".to_string()) + } else if let Some(incident) = incidents.first() { + match incident.incident_type.as_str() { + "turn_stuck" => Some("turn_stuck".to_string()), + "approval_timeout" => Some("approval_timeout".to_string()), + "user_input_timeout" => Some("user_input_timeout".to_string()), + "tool_failed" => Some("tool_failed".to_string()), + "provider_error" => Some("provider_error".to_string()), + "runtime_warning" => Some("runtime_warning".to_string()), + "runtime_error" => Some("runtime_error".to_string()), + other => Some(other.to_string()), + } + } else if let Some(outcome) = last_outcome { + match outcome.outcome_type.as_str() { + "interrupted" => Some("interrupted".to_string()), + "failed_tool" => Some("failed_tool".to_string()), + "failed_provider" => Some("failed_provider".to_string()), + "failed_model" => Some("failed_model".to_string()), + other => Some(other.to_string()), + } + } else if latest_failed_command.is_some() { + Some("failed_command".to_string()) + } else if latest_context_compaction.is_some() || latest_warning.is_some() { + Some("context_risk".to_string()) + } else if !queued_turns.is_empty() { + Some("queued_wait".to_string()) + } else { + None + }; + let primary_blocking_summary = if runtime_interrupt_marker.is_some() && latest_turn_running { + runtime_interrupt_marker.map(|marker| marker.reason.clone()) + } else if let Some(request) = pending_requests.first() { + request.title.clone().or_else(|| { + Some(format!( + "线程正在等待{}", + if is_tool_confirmation_request(&request.request_type) { + "工具确认" + } else if is_user_input_request(&request.request_type) { + "人工输入" + } else { + "外部请求响应" + } + )) + }) + } else if latest_turn_aborted { + last_outcome + .and_then(|outcome| outcome.summary.clone().or_else(|| outcome.primary_cause.clone())) + .or_else(|| Some("最近一次回合已被中断".to_string())) + } else if let Some(incident) = incidents.first() { + incident + .details + .as_ref() + .map(|value| match value { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }) + .or_else(|| Some(incident.title.clone())) + } else if let Some(outcome) = last_outcome { + outcome + .summary + .clone() + .or_else(|| outcome.primary_cause.clone()) + } else if let Some(sample) = latest_failed_command.as_ref() { + sample + .error + .clone() + .or_else(|| Some(format!("命令执行失败:{}", sample.command))) + } else if let Some(sample) = latest_warning.as_ref() { + Some(sample.message.clone()) + } else if !queued_turns.is_empty() { + Some("当前没有活跃回合,线程里仍有排队任务等待执行".to_string()) + } else { + None + }; + + Some(AgentRuntimeThreadDiagnostics { + latest_turn_status, + latest_turn_started_at, + latest_turn_completed_at, + latest_turn_updated_at, + latest_turn_elapsed_seconds, + latest_turn_stalled_seconds, + latest_turn_error_message, + interrupt_reason, + runtime_interrupt_source, + runtime_interrupt_requested_at, + runtime_interrupt_wait_seconds, + warning_count, + context_compaction_count, + failed_tool_call_count, + failed_command_count, + pending_request_count: pending_requests.len(), + oldest_pending_request_wait_seconds, + primary_blocking_kind, + primary_blocking_summary, + latest_warning, + latest_context_compaction, + latest_failed_tool, + latest_failed_command, + latest_pending_request, + }) +} + fn is_tool_confirmation_request(request_type: &str) -> bool { let normalized = request_type.to_ascii_lowercase(); normalized.contains("tool") || normalized.contains("approval") @@ -1206,6 +1692,20 @@ mod tests { assert_eq!(thread_read.pending_requests[0].id, "req-1"); assert_eq!(thread_read.incidents.len(), 1); assert_eq!(thread_read.incidents[0].incident_type, "waiting_user_input"); + assert_eq!( + thread_read + .diagnostics + .as_ref() + .and_then(|diagnostics| diagnostics.primary_blocking_kind.as_deref()), + Some("waiting_user_input") + ); + assert_eq!( + thread_read + .diagnostics + .as_ref() + .map(|diagnostics| diagnostics.pending_request_count), + Some(1) + ); } #[test] @@ -1315,6 +1815,227 @@ mod tests { assert_eq!(thread_read.incidents[0].severity, "high"); } + #[test] + fn thread_read_should_collect_backend_diagnostics() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-diag".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "生成研究简报".to_string(), + status: AgentThreadTurnStatus::Aborted, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: Some("2026-03-23T09:10:30Z".to_string()), + error_message: Some("用户主动中断".to_string()), + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:30Z".to_string(), + }], + vec![ + AgentThreadItem { + id: "item-warning".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-diag".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-23T09:10:10Z".to_string(), + completed_at: Some("2026-03-23T09:10:10Z".to_string()), + updated_at: "2026-03-23T09:10:10Z".to_string(), + payload: AgentThreadItemPayload::Warning { + message: "长对话和多次上下文压缩会降低模型准确性".to_string(), + code: Some("context_compaction_accuracy".to_string()), + }, + }, + AgentThreadItem { + id: "item-compaction".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-diag".to_string(), + sequence: 2, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-23T09:10:20Z".to_string(), + completed_at: Some("2026-03-23T09:10:20Z".to_string()), + updated_at: "2026-03-23T09:10:20Z".to_string(), + payload: AgentThreadItemPayload::ContextCompaction { + stage: "runtime".to_string(), + trigger: Some("token_budget".to_string()), + detail: Some("保留研究目标与来源摘要".to_string()), + }, + }, + AgentThreadItem { + id: "item-tool".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-diag".to_string(), + sequence: 3, + status: AgentThreadItemStatus::Failed, + started_at: "2026-03-23T09:10:25Z".to_string(), + completed_at: Some("2026-03-23T09:10:25Z".to_string()), + updated_at: "2026-03-23T09:10:25Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "web_search".to_string(), + arguments: None, + output: None, + success: Some(false), + error: Some("429 rate limit".to_string()), + metadata: None, + }, + }, + AgentThreadItem { + id: "item-command".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-diag".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Failed, + started_at: "2026-03-23T09:10:26Z".to_string(), + completed_at: Some("2026-03-23T09:10:26Z".to_string()), + updated_at: "2026-03-23T09:10:26Z".to_string(), + payload: AgentThreadItemPayload::CommandExecution { + command: "npm run build".to_string(), + cwd: "/tmp/workspace".to_string(), + aggregated_output: None, + exit_code: Some(1), + error: Some("Command failed with exit code 1".to_string()), + }, + }, + ], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + let diagnostics = thread_read.diagnostics.expect("应生成 diagnostics"); + + assert_eq!(diagnostics.latest_turn_status.as_deref(), Some("aborted")); + assert_eq!( + diagnostics.latest_turn_started_at.as_deref(), + Some("2026-03-23T09:10:00Z") + ); + assert_eq!( + diagnostics.latest_turn_completed_at.as_deref(), + Some("2026-03-23T09:10:30Z") + ); + assert_eq!( + diagnostics.latest_turn_updated_at.as_deref(), + Some("2026-03-23T09:10:30Z") + ); + assert_eq!(diagnostics.latest_turn_elapsed_seconds, Some(30)); + assert_eq!(diagnostics.latest_turn_stalled_seconds, None); + assert_eq!( + diagnostics.latest_turn_error_message.as_deref(), + Some("用户主动中断") + ); + assert_eq!( + diagnostics.interrupt_reason.as_deref(), + Some("用户主动中断") + ); + assert_eq!(diagnostics.warning_count, 1); + assert_eq!(diagnostics.context_compaction_count, 1); + assert_eq!(diagnostics.failed_tool_call_count, 1); + assert_eq!(diagnostics.failed_command_count, 1); + assert_eq!(diagnostics.pending_request_count, 0); + assert_eq!( + diagnostics.primary_blocking_kind.as_deref(), + Some("interrupted") + ); + assert_eq!( + diagnostics.primary_blocking_summary.as_deref(), + Some("最近一次回合已被中断") + ); + assert_eq!( + diagnostics + .latest_warning + .as_ref() + .and_then(|sample| sample.code.as_deref()), + Some("context_compaction_accuracy") + ); + assert_eq!( + diagnostics + .latest_context_compaction + .as_ref() + .map(|sample| sample.stage.as_str()), + Some("runtime") + ); + assert_eq!( + diagnostics + .latest_failed_tool + .as_ref() + .map(|sample| sample.tool_name.as_str()), + Some("web_search") + ); + assert_eq!( + diagnostics + .latest_failed_tool + .as_ref() + .and_then(|sample| sample.error.as_deref()), + Some("429 rate limit") + ); + assert_eq!( + diagnostics + .latest_failed_command + .as_ref() + .map(|sample| sample.command.as_str()), + Some("npm run build") + ); + assert_eq!( + diagnostics + .latest_failed_command + .as_ref() + .and_then(|sample| sample.exit_code), + Some(1) + ); + } + + #[test] + fn thread_read_should_surface_runtime_interrupt_marker_while_running() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-running".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续生成研究简报".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: None, + error_message: None, + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:28Z".to_string(), + }], + Vec::new(), + ); + let marker = lime_agent::RuntimeInterruptMarker { + source: "user".to_string(), + reason: "用户主动停止当前执行".to_string(), + requested_at: "2026-03-23T09:10:29Z".to_string(), + }; + + let thread_read = AgentRuntimeThreadReadModel::from_parts( + &detail, + &[], + Vec::new(), + None, + Vec::new(), + Some(&marker), + ); + let diagnostics = thread_read.diagnostics.expect("应生成 diagnostics"); + + assert_eq!(thread_read.status, "interrupting"); + assert_eq!(thread_read.interrupt_state.as_deref(), Some("interrupting")); + assert_eq!( + diagnostics.interrupt_reason.as_deref(), + Some("用户主动停止当前执行") + ); + assert_eq!( + diagnostics.runtime_interrupt_source.as_deref(), + Some("user") + ); + assert_eq!( + diagnostics.runtime_interrupt_requested_at.as_deref(), + Some("2026-03-23T09:10:29Z") + ); + assert_eq!( + diagnostics.primary_blocking_kind.as_deref(), + Some("interrupting") + ); + assert_eq!( + diagnostics.primary_blocking_summary.as_deref(), + Some("用户主动停止当前执行") + ); + } + #[test] fn replay_request_should_rebuild_pending_action_payload() { let detail = build_session_detail( diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index 1ee67760f..62b8903a6 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -9,9 +9,11 @@ import { SettingsTabs } from "@/types/settings"; const { mockBuildClawAgentParams, + mockCreateAutomationJob, mockHomeShellExecutionStrategy, mockHomeShellModel, mockHomeShellProviderType, + mockListProjects, mockSetExecutionStrategy, mockSetModel, mockSetProviderType, @@ -23,6 +25,9 @@ const { mockUseClawSolutions, mockRecordClawSolutionUsage, mockClawSolutions, + mockUseServiceSkills, + mockRecordServiceSkillUsage, + mockServiceSkills, } = vi.hoisted(() => { const mockClawSolutions = [ { @@ -56,16 +61,113 @@ const { }, ]; + const mockServiceSkills = [ + { + id: "short-video-script-replication", + title: "复制短视频脚本", + summary: "围绕参考视频的结构和节奏,输出一版可继续加工的脚本。", + category: "视频创作", + outputHint: "脚本大纲 + 镜头节奏", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "agent_turn", + executionLocation: "client_default", + themeTarget: "video", + version: "seed-v1", + slotSchema: [ + { + key: "reference_video", + label: "参考视频链接/素材", + type: "url", + required: true, + placeholder: "输入视频链接", + }, + ], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "本地即时执行", + runnerTone: "emerald", + runnerDescription: "客户端起步版可直接进入工作区执行。", + actionLabel: "填写参数", + }, + { + id: "daily-trend-briefing", + title: "每日趋势摘要", + summary: "围绕指定平台与关键词输出趋势摘要。", + category: "社媒运营", + outputHint: "趋势摘要 + 调度建议", + source: "cloud_catalog", + runnerType: "scheduled", + defaultExecutorBinding: "automation_job", + executionLocation: "client_default", + themeTarget: "social-media", + version: "seed-v1", + slotSchema: [ + { + key: "platform", + label: "监测平台", + type: "platform", + required: true, + placeholder: "选择平台", + defaultValue: "x", + options: [{ value: "x", label: "X / Twitter" }], + }, + { + key: "industry_keywords", + label: "行业关键词", + type: "textarea", + required: true, + placeholder: "输入关键词", + }, + { + key: "schedule_time", + label: "推送时间", + type: "schedule_time", + required: false, + placeholder: "例如 每天 09:00", + defaultValue: "每天 09:00", + }, + ], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "本地计划任务", + runnerTone: "sky", + runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。", + actionLabel: "先做方案", + }, + ]; + const mockRecordClawSolutionUsage = vi.fn(); + const mockRecordServiceSkillUsage = vi.fn(); return { mockBuildClawAgentParams: vi.fn((overrides?: Record) => ({ agentEntry: "claw", ...(overrides || {}), })), + mockCreateAutomationJob: vi.fn(async (request: Record) => ({ + id: "automation-job-1", + ...request, + })), mockHomeShellProviderType: { current: "mock-provider" }, mockHomeShellModel: { current: "mock-model" }, mockHomeShellExecutionStrategy: { current: "react" }, + mockListProjects: vi.fn(async () => [ + { + id: "project-1", + name: "项目一", + workspaceType: "general", + rootPath: "/tmp/project-1", + isDefault: false, + createdAt: 0, + updatedAt: 0, + isFavorite: false, + isArchived: false, + tags: [], + }, + ]), mockSetProviderType: vi.fn(), mockSetModel: vi.fn(), mockSetExecutionStrategy: vi.fn(), @@ -94,6 +196,15 @@ const { })), mockRecordClawSolutionUsage, mockClawSolutions, + mockUseServiceSkills: vi.fn(() => ({ + skills: mockServiceSkills, + isLoading: false, + error: null, + refresh: vi.fn(), + recordUsage: mockRecordServiceSkillUsage, + })), + mockRecordServiceSkillUsage, + mockServiceSkills, }; }); @@ -200,6 +311,14 @@ vi.mock("@/lib/api/clawSolutions", () => ({ prepareClawSolution: mockPrepareClawSolution, })); +vi.mock("@/lib/api/automation", () => ({ + createAutomationJob: mockCreateAutomationJob, +})); + +vi.mock("@/lib/api/project", () => ({ + listProjects: mockListProjects, +})); + vi.mock("@/hooks/useConfiguredProviders", () => ({ loadConfiguredProviders: mockLoadConfiguredProviders, })); @@ -239,6 +358,192 @@ vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({ ), })); +vi.mock("./service-skills/useServiceSkills", () => ({ + useServiceSkills: mockUseServiceSkills, +})); + +vi.mock("./service-skills/ServiceSkillHomePanel", () => ({ + ServiceSkillHomePanel: ({ + skills, + onSelect, + }: { + skills: Array<{ id: string; title: string }>; + onSelect: (skill: { id: string; title: string }) => void; + }) => ( + <> + {skills.map((skill) => ( + + ))} + + ), +})); + +vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ + ServiceSkillLaunchDialog: ({ + skill, + open, + onLaunch, + onCreateAutomation, + }: { + skill: { id: string; title: string; runnerType?: string } | null; + open: boolean; + onLaunch: ( + skill: { id: string; title: string; runnerType?: string }, + slotValues: Record, + ) => void; + onCreateAutomation?: ( + skill: { id: string; title: string; runnerType?: string }, + slotValues: Record, + ) => void; + }) => + open && skill ? ( + <> + + {skill.runnerType === "scheduled" && onCreateAutomation ? ( + + ) : null} + + ) : null, +})); + +vi.mock("@/components/settings-v2/system/automation/AutomationJobDialog", () => ({ + AutomationJobDialog: ({ + open, + mode, + initialValues, + onSubmit, + }: { + open: boolean; + mode: "create" | "edit"; + initialValues?: Record | null; + onSubmit: (payload: { + mode: "create"; + request: Record; + }) => Promise; + }) => + open ? ( +
+ {mode} + + {typeof initialValues?.schedule_kind === "string" + ? initialValues.schedule_kind + : "-"} + + + {typeof initialValues?.name === "string" ? initialValues.name : "-"} + + +
+ ) : null, +})); + const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; beforeEach(() => { @@ -257,8 +562,29 @@ beforeEach(() => { refresh: vi.fn(), recordUsage: mockRecordClawSolutionUsage, })); + mockUseServiceSkills.mockImplementation(() => ({ + skills: mockServiceSkills, + isLoading: false, + error: null, + refresh: vi.fn(), + recordUsage: mockRecordServiceSkillUsage, + })); mockLoadConfiguredProviders.mockResolvedValue([]); mockLoadProviderModels.mockResolvedValue([]); + mockListProjects.mockResolvedValue([ + { + id: "project-1", + name: "项目一", + workspaceType: "general", + rootPath: "/tmp/project-1", + isDefault: false, + createdAt: 0, + updatedAt: 0, + isFavorite: false, + isArchived: false, + tags: [], + }, + ]); mockFilterModelsByTheme.mockImplementation( (_theme: string | undefined, models: unknown[]) => ({ models, @@ -667,6 +993,143 @@ describe("AgentChatHomeShell", () => { }); }); + it("点击服务型技能后应完成补参并进入对应工作区", async () => { + const onEnterWorkspace = vi.fn(); + const { container } = renderShell({ + onNavigate: undefined, + onEnterWorkspace, + }); + + await flushEffects(); + + const serviceSkillButton = container.querySelector( + '[data-testid="home-shell-service-skill-short-video-script-replication"]', + ) as HTMLButtonElement | null; + + expect(serviceSkillButton).toBeTruthy(); + + act(() => { + serviceSkillButton?.click(); + }); + + await flushEffects(); + + const launchButton = container.querySelector( + '[data-testid="home-shell-service-skill-launch"]', + ) as HTMLButtonElement | null; + + expect(launchButton).toBeTruthy(); + + act(() => { + launchButton?.click(); + }); + + await flushEffects(); + + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + theme: "video", + initialCreationMode: "guided", + initialUserPrompt: expect.stringContaining( + "[服务型技能] 复制短视频脚本", + ), + }), + ); + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + initialUserPrompt: expect.stringContaining( + "- 参考视频链接/素材: https://example.com/video", + ), + }), + ); + expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "short-video-script-replication", + runnerType: "instant", + }); + }); + + it("点击定时服务型技能创建任务后应先建本地 automation 再进入工作区", async () => { + const onEnterWorkspace = vi.fn(); + const { container } = renderShell({ + onNavigate: undefined, + onEnterWorkspace, + }); + + await flushEffects(); + + const serviceSkillButton = container.querySelector( + '[data-testid="home-shell-service-skill-daily-trend-briefing"]', + ) as HTMLButtonElement | null; + + expect(serviceSkillButton).toBeTruthy(); + + act(() => { + serviceSkillButton?.click(); + }); + + await flushEffects(); + + const createAutomationButton = container.querySelector( + '[data-testid="home-shell-service-skill-create-automation"]', + ) as HTMLButtonElement | null; + + expect(createAutomationButton).toBeTruthy(); + + act(() => { + createAutomationButton?.click(); + }); + + await flushEffects(); + + const automationDialog = container.querySelector( + '[data-testid="home-shell-automation-dialog"]', + ) as HTMLDivElement | null; + const automationSubmitButton = container.querySelector( + '[data-testid="home-shell-automation-submit"]', + ) as HTMLButtonElement | null; + + expect(automationDialog).toBeTruthy(); + expect(container.textContent).toContain("create"); + expect(container.textContent).toContain("cron"); + expect(container.textContent).toContain("每日趋势摘要"); + expect(automationSubmitButton).toBeTruthy(); + + act(() => { + automationSubmitButton?.click(); + }); + + await flushEffects(); + + expect(mockCreateAutomationJob).toHaveBeenCalledWith( + expect.objectContaining({ + workspace_id: "project-1", + execution_mode: "skill", + schedule: { + kind: "cron", + expr: "00 09 * * *", + tz: expect.any(String), + }, + payload: expect.objectContaining({ + kind: "agent_turn", + prompt: expect.stringContaining("[服务型技能] 每日趋势摘要"), + }), + }), + ); + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + theme: "social-media", + initialCreationMode: "guided", + initialUserPrompt: expect.stringContaining("[服务型技能] 每日趋势摘要"), + }), + ); + expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "daily-trend-briefing", + runnerType: "scheduled", + }); + }); + it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => { const onNavigate = vi.fn(); const onEnterWorkspace = vi.fn(); diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index c5e93abf3..af482cfae 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -1,7 +1,14 @@ import { useCallback, useEffect, useState } from "react"; import styled from "styled-components"; import { toast } from "sonner"; +import { createAutomationJob } from "@/lib/api/automation"; import { prepareClawSolution } from "@/lib/api/clawSolutions"; +import { listProjects, type Project } from "@/lib/api/project"; +import { + AutomationJobDialog, + type AutomationJobDialogInitialValues, + type AutomationJobDialogSubmit, +} from "@/components/settings-v2/system/automation/AutomationJobDialog"; import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { EmptyState } from "./components/EmptyState"; @@ -35,6 +42,18 @@ import { resolveHomeShellWorkspaceEntry, type HomeShellEnterWorkspacePayload, } from "./homeShellEntry"; +import { useServiceSkills } from "./service-skills/useServiceSkills"; +import { ServiceSkillHomePanel } from "./service-skills/ServiceSkillHomePanel"; +import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDialog"; +import { composeServiceSkillPrompt } from "./service-skills/promptComposer"; +import { + buildServiceSkillAutomationInitialValues, + supportsServiceSkillLocalAutomation, +} from "./service-skills/automationDraft"; +import type { + ServiceSkillHomeItem, + ServiceSkillSlotValues, +} from "./service-skills/types"; const PageContainer = styled.div<{ $compact?: boolean }>` display: flex; @@ -142,6 +161,67 @@ function getErrorMessage(error: unknown): string { return "请稍后重试"; } +function resolveFallbackProjectType(theme?: string): Project["workspaceType"] { + switch (theme) { + case "social-media": + case "poster": + case "music": + case "knowledge": + case "planning": + case "document": + case "video": + case "novel": + case "general": + return theme; + default: + return "general"; + } +} + +function buildFallbackAutomationWorkspace( + projectId: string, + theme?: string, +): Project { + return { + id: projectId, + name: projectId, + workspaceType: resolveFallbackProjectType(theme), + rootPath: "", + isDefault: false, + createdAt: 0, + updatedAt: 0, + isFavorite: false, + isArchived: false, + tags: [], + }; +} + +function prioritizeAutomationWorkspaces( + workspaces: Project[], + projectId?: string | null, + theme?: string, +): Project[] { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + return workspaces; + } + + const matched = workspaces.find((workspace) => workspace.id === normalizedProjectId); + const fallbackWorkspace = + matched ?? buildFallbackAutomationWorkspace(normalizedProjectId, theme); + const remaining = workspaces.filter((workspace) => workspace.id !== normalizedProjectId); + + return [fallbackWorkspace, ...remaining]; +} + +interface PendingServiceSkillAutomationLaunch { + enterWorkspacePayload: HomeShellEnterWorkspacePayload; + usage: { + skillId: string; + runnerType: ServiceSkillHomeItem["runnerType"]; + }; +} + export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry"; interface AgentChatHomeShellProps { @@ -196,6 +276,22 @@ export function AgentChatHomeShell({ error: clawSolutionsError, recordUsage: recordClawSolutionUsage, } = useClawSolutions(activeTheme === "general"); + const { + skills: serviceSkills, + isLoading: serviceSkillsLoading, + error: serviceSkillsError, + recordUsage: recordServiceSkillUsage, + } = useServiceSkills(activeTheme === "general"); + const [selectedServiceSkill, setSelectedServiceSkill] = + useState(null); + const [serviceSkillDialogOpen, setServiceSkillDialogOpen] = useState(false); + const [automationDialogOpen, setAutomationDialogOpen] = useState(false); + const [automationDialogInitialValues, setAutomationDialogInitialValues] = + useState(null); + const [automationWorkspaces, setAutomationWorkspaces] = useState([]); + const [automationJobSaving, setAutomationJobSaving] = useState(false); + const [pendingServiceSkillAutomation, setPendingServiceSkillAutomation] = + useState(null); useEffect(() => { setActiveTheme(normalizeInitialTheme(initialTheme)); @@ -216,6 +312,14 @@ export function AgentChatHomeShell({ toast.error(`加载 Claw 方案失败:${clawSolutionsError}`); }, [activeTheme, clawSolutionsError]); + useEffect(() => { + if (activeTheme !== "general" || !serviceSkillsError) { + return; + } + + toast.error(`加载服务型技能失败:${serviceSkillsError}`); + }, [activeTheme, serviceSkillsError]); + const handleRefreshSkills = useCallback(async () => { await refreshSkills(true); }, [refreshSkills]); @@ -366,6 +470,156 @@ export function AgentChatHomeShell({ ], ); + const handleServiceSkillSelect = useCallback((skill: ServiceSkillHomeItem) => { + setSelectedServiceSkill(skill); + setServiceSkillDialogOpen(true); + }, []); + + const handleServiceSkillLaunch = useCallback( + async ( + skill: ServiceSkillHomeItem, + slotValues: ServiceSkillSlotValues, + ) => { + const prompt = composeServiceSkillPrompt({ + skill, + slotValues, + userInput: input.trim() || undefined, + }); + + if (skill.runnerType !== "instant") { + toast.info("当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。"); + } + + const entered = handleEnterWorkspace({ + prompt, + themeOverride: skill.themeTarget, + }); + + if (!entered) { + return; + } + + recordServiceSkillUsage({ + skillId: skill.id, + runnerType: skill.runnerType, + }); + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + }, + [handleEnterWorkspace, input, recordServiceSkillUsage], + ); + + const handleServiceSkillAutomationSetup = useCallback( + async ( + skill: ServiceSkillHomeItem, + slotValues: ServiceSkillSlotValues, + ) => { + if (!supportsServiceSkillLocalAutomation(skill)) { + await handleServiceSkillLaunch(skill, slotValues); + return; + } + + const normalizedProjectId = normalizeProjectId(currentProjectId); + if (!normalizedProjectId) { + toast.error("缺少项目工作区,请先选择项目后再创建本地自动化任务。"); + return; + } + + const prompt = composeServiceSkillPrompt({ + skill, + slotValues, + userInput: input.trim() || undefined, + }); + + try { + let workspaces: Project[]; + try { + workspaces = prioritizeAutomationWorkspaces( + await listProjects(), + normalizedProjectId, + skill.themeTarget ?? activeTheme, + ); + } catch { + workspaces = [ + buildFallbackAutomationWorkspace( + normalizedProjectId, + skill.themeTarget ?? activeTheme, + ), + ]; + } + + setAutomationWorkspaces(workspaces); + setAutomationDialogInitialValues( + buildServiceSkillAutomationInitialValues({ + skill, + slotValues, + userInput: input.trim() || undefined, + workspaceId: normalizedProjectId, + }), + ); + setPendingServiceSkillAutomation({ + enterWorkspacePayload: { + prompt, + themeOverride: skill.themeTarget, + }, + usage: { + skillId: skill.id, + runnerType: skill.runnerType, + }, + }); + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + setAutomationDialogOpen(true); + } catch (error) { + toast.error(`准备本地自动化任务失败:${getErrorMessage(error)}`); + } + }, + [activeTheme, currentProjectId, handleServiceSkillLaunch, input], + ); + + const handleAutomationDialogOpenChange = useCallback((open: boolean) => { + setAutomationDialogOpen(open); + if (!open) { + setAutomationDialogInitialValues(null); + setPendingServiceSkillAutomation(null); + } + }, []); + + const handleAutomationDialogSubmit = useCallback( + async (payload: AutomationJobDialogSubmit) => { + if (payload.mode !== "create") { + throw new Error("服务型技能入口当前只支持创建新的本地自动化任务"); + } + + setAutomationJobSaving(true); + try { + const createdJob = await createAutomationJob(payload.request); + toast.success(`本地自动化任务已创建:${createdJob.name}`); + + const pendingLaunch = pendingServiceSkillAutomation; + setAutomationDialogOpen(false); + setAutomationDialogInitialValues(null); + setPendingServiceSkillAutomation(null); + + if (!pendingLaunch) { + return; + } + + recordServiceSkillUsage(pendingLaunch.usage); + const entered = handleEnterWorkspace(pendingLaunch.enterWorkspacePayload); + if (!entered) { + toast.error("自动化任务已创建,但进入工作区失败,请稍后手动打开。"); + } + } catch (error) { + toast.error(`创建本地自动化任务失败:${getErrorMessage(error)}`); + throw error; + } finally { + setAutomationJobSaving(false); + } + }, + [handleEnterWorkspace, pendingServiceSkillAutomation, recordServiceSkillUsage], + ); + const handleRecommendationClick = useCallback( (shortLabel: string, fullPrompt: string) => { setInput(fullPrompt); @@ -473,11 +727,18 @@ export function AgentChatHomeShell({ onRecommendationClick={handleRecommendationClick} supportingSlotOverride={ activeTheme === "general" ? ( - + <> + + + ) : undefined } characters={projectMemory?.characters || []} @@ -511,6 +772,27 @@ export function AgentChatHomeShell({ }); }} /> + { + setServiceSkillDialogOpen(open); + if (!open) { + setSelectedServiceSkill(null); + } + }} + onLaunch={handleServiceSkillLaunch} + onCreateAutomation={handleServiceSkillAutomationSetup} + /> + diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 584f1ad58..3f7283020 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -2015,7 +2015,9 @@ export function AgentChatWorkspace({ taskFilesRef, socialStageLogRef, setDocumentVersionStatusMap, - saveSessionFile, + saveSessionFile: async (fileName, content) => { + await saveSessionFile(fileName, content); + }, syncGeneralArtifactToResource, upsertGeneralArtifact, setSelectedArtifactId, @@ -2222,12 +2224,14 @@ export function AgentChatWorkspace({ threadRead, pendingActions, submittedActionsInFlight, + messages: displayMessages, queuedTurns, resumeThread, replayPendingAction, promoteQueuedTurn, removeQueuedTurn, latestAssistantMessageId, + sessionIdForDiagnostics: sessionId || null, themeWorkbenchEntryPrompt, handleRestartThemeWorkbenchEntryPrompt, handleContinueThemeWorkbenchEntryPrompt, @@ -2419,7 +2423,7 @@ export function AgentChatWorkspace({ selectedFileId, handleHarnessLoadFilePreview, setCanvasWorkbenchLayoutMode, - workspacePathMissing, + workspacePathMissing: Boolean(workspacePathMissing), workspaceHealthError, }); diff --git a/src/components/agent/chat/components/AgentIncidentPanel.test.tsx b/src/components/agent/chat/components/AgentIncidentPanel.test.tsx index cd9d6272c..cb73b5cb0 100644 --- a/src/components/agent/chat/components/AgentIncidentPanel.test.tsx +++ b/src/components/agent/chat/components/AgentIncidentPanel.test.tsx @@ -59,6 +59,7 @@ describe("AgentIncidentPanel", () => { const container = renderPanel([ { id: "incident-1", + incidentType: "approval_timeout", title: "审批等待超过阈值", detail: "当前线程等待工具确认时间过长", statusLabel: "进行中", @@ -67,6 +68,7 @@ describe("AgentIncidentPanel", () => { }, { id: "incident-2", + incidentType: "waiting_user_input", title: "线程正在等待人工处理", detail: "等待你确认是否继续发布", statusLabel: "进行中", diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 8c7c29c20..42dd8357e 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -4,8 +4,14 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; -import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, + Message, +} from "../types"; import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; +import type { HarnessSessionState } from "../utils/harnessState"; const { mockToast } = vi.hoisted(() => ({ mockToast: { @@ -67,10 +73,21 @@ function renderPanel(props?: { currentTurnId?: string | null; canInterrupt?: boolean; onInterruptCurrentTurn?: () => void | Promise; - onResumeThread?: () => void | Promise; - onReplayPendingRequest?: (requestId: string) => void | Promise; + onResumeThread?: () => boolean | Promise; + onReplayPendingRequest?: (requestId: string) => boolean | Promise; onLocatePendingRequest?: (requestId: string) => void; - onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise; + onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise; + harnessState?: HarnessSessionState | null; + messages?: Message[]; + diagnosticRuntimeContext?: { + sessionId?: string | null; + workspaceId?: string | null; + providerType?: string | null; + model?: string | null; + executionStrategy?: string | null; + activeTheme?: string | null; + selectedTeamLabel?: string | null; + } | null; }) { const container = document.createElement("div"); document.body.appendChild(container); @@ -91,6 +108,9 @@ function renderPanel(props?: { onReplayPendingRequest={props?.onReplayPendingRequest} onLocatePendingRequest={props?.onLocatePendingRequest} onPromoteQueuedTurn={props?.onPromoteQueuedTurn} + harnessState={props?.harnessState} + messages={props?.messages} + diagnosticRuntimeContext={props?.diagnosticRuntimeContext} />, ); }); @@ -484,6 +504,51 @@ describe("AgentThreadReliabilityPanel", () => { thread_id: "thread-1", status: "aborted", active_turn_id: "turn-1", + diagnostics: { + latest_turn_status: "aborted", + latest_turn_started_at: "2026-03-23T10:00:00Z", + latest_turn_completed_at: "2026-03-23T10:02:00Z", + latest_turn_updated_at: "2026-03-23T10:03:00Z", + latest_turn_elapsed_seconds: 120, + latest_turn_error_message: "浏览器页面已关闭", + interrupt_reason: "浏览器页面已关闭", + runtime_interrupt_source: "user", + runtime_interrupt_requested_at: "2026-03-23T10:01:58Z", + runtime_interrupt_wait_seconds: 2, + warning_count: 1, + context_compaction_count: 1, + failed_tool_call_count: 1, + failed_command_count: 1, + pending_request_count: 0, + primary_blocking_kind: "tool_failed", + primary_blocking_summary: "页面上下文已销毁", + latest_warning: { + item_id: "warning-1", + code: "context_compaction_accuracy", + message: "长对话和多次上下文压缩会降低模型准确性", + updated_at: "2026-03-23T10:01:30Z", + }, + latest_context_compaction: { + item_id: "compaction-1", + stage: "runtime", + trigger: "token_budget", + detail: "保留研究目标与最近来源摘要", + updated_at: "2026-03-23T10:01:00Z", + }, + latest_failed_tool: { + item_id: "tool-1", + tool_name: "browser_click", + error: "页面上下文已销毁", + updated_at: "2026-03-23T10:02:00Z", + }, + latest_failed_command: { + item_id: "cmd-1", + command: "npm run build", + exit_code: 1, + error: "Command failed with exit code 1", + updated_at: "2026-03-23T10:01:50Z", + }, + }, pending_requests: [], last_outcome: { thread_id: "thread-1", @@ -520,6 +585,80 @@ describe("AgentThreadReliabilityPanel", () => { }, ], currentTurnId: "turn-1", + harnessState: { + runtimeStatus: { + phase: "context", + title: "正在整理研究上下文", + detail: "最近一次压缩后继续生成研究简报。", + checkpoints: ["已整理来源", "正在回填摘要"], + }, + pendingApprovals: [], + latestContextTrace: [ + { + stage: "context_compaction", + detail: "保留研究目标与最近来源摘要", + }, + ], + plan: { + phase: "ready", + items: [ + { id: "todo-1", content: "归纳研究目标", status: "completed" }, + { id: "todo-2", content: "输出风险点", status: "in_progress" }, + ], + summaryText: "先回填研究简报,再补风险追踪建议。", + }, + activity: { + planning: 1, + filesystem: 0, + execution: 0, + web: 2, + skills: 0, + delegation: 0, + }, + delegatedTasks: [], + outputSignals: [ + { + id: "signal-1", + toolCallId: "tool-1", + toolName: "web_search", + title: "联网检索摘要", + summary: "已检索 3 个来源", + preview: "来源覆盖官网、新闻和公告", + }, + ], + activeFileWrites: [], + recentFileEvents: [], + hasSignals: true, + }, + messages: [ + { + id: "msg-user-1", + role: "user", + content: "请围绕这个主题先给我做一版网页研究简报", + timestamp: new Date("2026-03-23T09:59:00Z"), + }, + { + id: "msg-assistant-1", + role: "assistant", + content: "我先整理研究目标、来源、核心发现和风险点。", + timestamp: new Date("2026-03-23T10:00:00Z"), + runtimeStatus: { + phase: "context", + title: "正在整理研究上下文", + detail: "压缩后继续生成简报", + checkpoints: ["研究目标", "来源摘要"], + }, + }, + ], + diagnosticRuntimeContext: { + sessionId: "session-diag-1", + workspaceId: "workspace-diag-1", + providerType: "openai", + model: "gpt-5.4", + executionStrategy: "react", + activeTheme: "general", + selectedTeamLabel: "研究协作队", + }, }); const copyButton = container.querySelector( @@ -538,6 +677,30 @@ describe("AgentThreadReliabilityPanel", () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("请按以下结构输出:"), ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### 运行环境"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("gpt-5.4"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### Harness 过程信号"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### 后端诊断聚合"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("主阻塞类型:tool_failed"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("中断来源:user"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("最近失败命令:npm run build"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### 最近消息片段"), + ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("### Incident"), ); @@ -556,6 +719,15 @@ describe("AgentThreadReliabilityPanel", () => { thread_id: "thread-json-1", status: "waiting_request", active_turn_id: "turn-json-1", + diagnostics: { + latest_turn_status: "running", + warning_count: 0, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + primary_blocking_kind: "waiting_user_input", + }, pending_requests: [ { id: "req-json-1", @@ -574,7 +746,7 @@ describe("AgentThreadReliabilityPanel", () => { id: "turn-json-1", thread_id: "thread-json-1", prompt_text: "继续执行 JSON 校验任务", - status: "waiting_request", + status: "running", started_at: "2026-03-23T10:00:00Z", created_at: "2026-03-23T10:00:00Z", updated_at: "2026-03-23T10:01:00Z", @@ -589,6 +761,45 @@ describe("AgentThreadReliabilityPanel", () => { status: "pending", }, ], + harnessState: { + runtimeStatus: null, + pendingApprovals: [], + latestContextTrace: [], + plan: { + phase: "idle", + items: [], + }, + activity: { + planning: 0, + filesystem: 0, + execution: 0, + web: 0, + skills: 0, + delegation: 0, + }, + delegatedTasks: [], + outputSignals: [], + activeFileWrites: [], + recentFileEvents: [], + hasSignals: true, + }, + messages: [ + { + id: "msg-json-1", + role: "assistant", + content: "正在等待你确认是否继续执行", + timestamp: new Date("2026-03-23T10:00:30Z"), + }, + ], + diagnosticRuntimeContext: { + sessionId: "session-json-1", + workspaceId: "workspace-json-1", + providerType: "openai", + model: "gpt-5.4-mini", + executionStrategy: "react", + activeTheme: "general", + selectedTeamLabel: "默认协作", + }, }); const jsonButton = container.querySelector( @@ -601,6 +812,18 @@ describe("AgentThreadReliabilityPanel", () => { await Promise.resolve(); }); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"runtime_context"'), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"backend_diagnostics"'), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"harness_state"'), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"recent_messages"'), + ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining('"thread_read"'), ); diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 277664d21..13898dc6d 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -18,7 +18,13 @@ import type { AgentRuntimeThreadReadModel, QueuedTurnSnapshot, } from "@/lib/api/agentRuntime"; -import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, + Message, +} from "../types"; +import type { HarnessSessionState } from "../utils/harnessState"; import { buildThreadReliabilityView, type ThreadReliabilityTone, @@ -26,6 +32,16 @@ import { import { AgentIncidentPanel } from "./AgentIncidentPanel"; import { AgentThreadOutcomeSummary } from "./AgentThreadOutcomeSummary"; +interface AgentThreadReliabilityDiagnosticContext { + sessionId?: string | null; + workspaceId?: string | null; + providerType?: string | null; + model?: string | null; + executionStrategy?: string | null; + activeTheme?: string | null; + selectedTeamLabel?: string | null; +} + interface AgentThreadReliabilityPanelProps { threadRead?: AgentRuntimeThreadReadModel | null; turns?: AgentThreadTurn[]; @@ -43,6 +59,9 @@ interface AgentThreadReliabilityPanelProps { queuedTurnId: string, ) => boolean | Promise; className?: string; + harnessState?: HarnessSessionState | null; + messages?: Message[]; + diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null; } function serializeClipboardPayload(value: unknown): string { @@ -53,6 +72,168 @@ function serializeClipboardPayload(value: unknown): string { ); } +function normalizeDiagnosticText(value?: string | null): string { + return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : ""; +} + +function truncateDiagnosticText(value?: string | null, maxLength = 240): string { + const normalized = normalizeDiagnosticText(value); + if (!normalized) { + return ""; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function summarizeThreadItemSignals(threadItems: AgentThreadItem[]) { + const warningItems = threadItems.filter((item) => item.type === "warning"); + const contextCompactionItems = threadItems.filter( + (item) => item.type === "context_compaction", + ); + const failedToolCalls = threadItems.filter( + (item): item is Extract => + item.type === "tool_call" && + (item.status === "failed" || item.success === false), + ); + + return { + warningCount: warningItems.length, + contextCompactionCount: contextCompactionItems.length, + failedToolCallCount: failedToolCalls.length, + latestWarnings: warningItems.slice(-3).map((item) => ({ + id: item.id, + code: item.code || null, + message: truncateDiagnosticText(item.message, 180), + status: item.status, + updated_at: item.updated_at, + })), + latestCompactions: contextCompactionItems.slice(-3).map((item) => ({ + id: item.id, + stage: item.stage, + trigger: item.trigger || null, + detail: truncateDiagnosticText(item.detail, 180), + status: item.status, + updated_at: item.updated_at, + })), + latestFailedTools: failedToolCalls.slice(-3).map((item) => ({ + id: item.id, + tool_name: item.tool_name, + error: truncateDiagnosticText(item.error, 180), + updated_at: item.updated_at, + })), + }; +} + +function summarizeRecentMessages(messages: Message[]) { + return messages.slice(-6).map((message) => ({ + id: message.id, + role: message.role, + timestamp: + message.timestamp instanceof Date + ? message.timestamp.toISOString() + : String(message.timestamp), + content_preview: truncateDiagnosticText(message.content, 320), + runtime_status: message.runtimeStatus + ? { + phase: message.runtimeStatus.phase, + title: message.runtimeStatus.title, + detail: truncateDiagnosticText(message.runtimeStatus.detail, 180), + checkpoints: message.runtimeStatus.checkpoints?.slice(0, 4) || [], + } + : null, + action_request_count: message.actionRequests?.length || 0, + action_request_titles: + message.actionRequests?.slice(0, 3).map((request) => + truncateDiagnosticText(request.prompt || request.toolName || request.requestId, 120), + ) || [], + tool_calls: + message.toolCalls?.slice(0, 4).map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + status: toolCall.status, + error: truncateDiagnosticText(toolCall.result?.error, 120), + })) || [], + context_trace: + message.contextTrace?.slice(-3).map((step) => ({ + stage: step.stage, + detail: truncateDiagnosticText(step.detail, 120), + })) || [], + artifact_titles: + message.artifacts?.slice(0, 4).map((artifact) => artifact.title) || [], + })); +} + +function summarizeHarnessState(harnessState?: HarnessSessionState | null) { + if (!harnessState) { + return null; + } + + return { + runtime_status: harnessState.runtimeStatus + ? { + phase: harnessState.runtimeStatus.phase, + title: harnessState.runtimeStatus.title, + detail: truncateDiagnosticText(harnessState.runtimeStatus.detail, 220), + checkpoints: harnessState.runtimeStatus.checkpoints?.slice(0, 6) || [], + metadata: harnessState.runtimeStatus.metadata || null, + } + : null, + plan: { + phase: harnessState.plan.phase, + summary_text: truncateDiagnosticText(harnessState.plan.summaryText, 220), + items: harnessState.plan.items.slice(0, 6), + }, + activity: harnessState.activity, + pending_approvals_count: harnessState.pendingApprovals.length, + latest_context_trace: + harnessState.latestContextTrace.slice(-5).map((step) => ({ + stage: step.stage, + detail: truncateDiagnosticText(step.detail, 160), + })) || [], + delegated_tasks: harnessState.delegatedTasks.slice(0, 6).map((task) => ({ + id: task.id, + title: task.title, + status: task.status, + task_type: task.taskType || null, + role: task.role || null, + model: task.model || null, + summary: truncateDiagnosticText(task.summary, 160), + })), + output_signals: harnessState.outputSignals.slice(0, 8).map((signal) => ({ + id: signal.id, + tool_name: signal.toolName, + title: signal.title, + summary: truncateDiagnosticText(signal.summary, 180), + preview: truncateDiagnosticText(signal.preview, 180), + output_file: signal.outputFile || null, + offload_file: signal.offloadFile || null, + artifact_path: signal.artifactPath || null, + exit_code: signal.exitCode, + truncated: signal.truncated || false, + offloaded: signal.offloaded || false, + })), + active_file_writes: harnessState.activeFileWrites.slice(0, 6).map((write) => ({ + id: write.id, + path: write.path, + display_name: write.displayName, + phase: write.phase, + status: write.status, + preview: truncateDiagnosticText(write.preview || write.latestChunk, 160), + })), + recent_file_events: harnessState.recentFileEvents.slice(0, 8).map((event) => ({ + id: event.id, + path: event.path, + display_name: event.displayName, + kind: event.kind, + action: event.action, + source_tool_name: event.sourceToolName, + preview: truncateDiagnosticText(event.preview, 140), + })), + }; +} + function resolveToneClassName(tone: ThreadReliabilityTone) { switch (tone) { case "running": @@ -88,16 +269,34 @@ function resolveStatShellClassName(tone: ThreadReliabilityTone) { } function buildReliabilityDiagnosticText(params: { + threadRead?: AgentRuntimeThreadReadModel | null; statusLabel: string; summary: string; view: ReturnType; + threadItems: AgentThreadItem[]; + messages: Message[]; + harnessState?: HarnessSessionState | null; + diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null; }): string { - const { statusLabel, summary, view } = params; + const { + threadRead, + statusLabel, + summary, + view, + threadItems, + messages, + harnessState, + diagnosticRuntimeContext, + } = params; + const threadItemSignals = summarizeThreadItemSignals(threadItems); + const recentMessages = summarizeRecentMessages(messages); const sections: string[] = [ "# Lime 线程可靠性诊断任务", "", "你现在是一名 AI 任务可靠性分析助手。请基于下面的线程可靠性数据,判断这次任务执行得好不好;如果执行不好,请找出根因,并给出可落地的修复建议。", "", + "如果“后端诊断聚合”与前端界面信号存在冲突,请优先以后端诊断聚合作为高可信事实源,再结合其他上下文判断。", + "", "请重点回答以下问题:", "1. 这次任务整体表现属于:好 / 一般 / 差?请先给结论。", "2. 直接导致中断、失败、等待或漂移的主要原因是什么?", @@ -117,6 +316,15 @@ function buildReliabilityDiagnosticText(params: { "", "## 诊断数据", "", + "### 运行环境", + `- 会话 ID:${diagnosticRuntimeContext?.sessionId || "未知"}`, + `- 工作区 ID:${diagnosticRuntimeContext?.workspaceId || "未知"}`, + `- Provider:${diagnosticRuntimeContext?.providerType || "未知"}`, + `- 模型:${diagnosticRuntimeContext?.model || "未知"}`, + `- 执行策略:${diagnosticRuntimeContext?.executionStrategy || "未知"}`, + `- 主题:${diagnosticRuntimeContext?.activeTheme || "未知"}`, + `- 协作方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`, + "", "### 当前状态", `- 状态:${statusLabel}`, `- 当前回合:${view.activeTurnLabel || "未知"}`, @@ -129,6 +337,21 @@ function buildReliabilityDiagnosticText(params: { `- 活跃 Incident:${view.activeIncidentCount}`, `- 排队回合:${view.queuedTurnCount}`, "", + "### 线程项信号", + `- warning 数量:${threadItemSignals.warningCount}`, + `- context compaction 数量:${threadItemSignals.contextCompactionCount}`, + `- 失败工具调用数量:${threadItemSignals.failedToolCallCount}`, + "", + "### Harness 过程信号", + `- runtimeStatus:${harnessState?.runtimeStatus?.title || "无"}`, + `- plan phase:${harnessState?.plan.phase || "无"}`, + `- plan items:${harnessState?.plan.items.length || 0}`, + `- output signals:${harnessState?.outputSignals.length || 0}`, + `- active file writes:${harnessState?.activeFileWrites.length || 0}`, + `- recent file events:${harnessState?.recentFileEvents.length || 0}`, + `- delegated tasks:${harnessState?.delegatedTasks.length || 0}`, + `- context trace steps:${harnessState?.latestContextTrace.length || 0}`, + "", "### 待处理请求", ]; @@ -193,6 +416,122 @@ function buildReliabilityDiagnosticText(params: { sections.push("- 暂无额外建议"); } + sections.push("", "### 最近 warning"); + if (threadItemSignals.latestWarnings.length > 0) { + for (const warning of threadItemSignals.latestWarnings) { + sections.push( + `- ${warning.code || "warning"}|${warning.message || "无消息"}|${warning.status}`, + ); + } + } else { + sections.push("- 无"); + } + + sections.push("", "### 最近 context compaction"); + if (threadItemSignals.latestCompactions.length > 0) { + for (const compaction of threadItemSignals.latestCompactions) { + sections.push( + `- ${compaction.stage}|${compaction.trigger || "未知触发"}|${compaction.detail || "无详情"}`, + ); + } + } else { + sections.push("- 无"); + } + + sections.push("", "### 后端诊断聚合"); + if (threadRead?.diagnostics) { + const diagnostics = threadRead.diagnostics; + sections.push(`- 最新回合状态:${diagnostics.latest_turn_status || "未知"}`); + sections.push( + `- 最新回合开始时间:${diagnostics.latest_turn_started_at || "未知"}`, + ); + sections.push( + `- 最新回合结束时间:${diagnostics.latest_turn_completed_at || "未知"}`, + ); + sections.push( + `- 最新回合更新时间:${diagnostics.latest_turn_updated_at || "未知"}`, + ); + sections.push( + `- 最新回合累计耗时(秒):${diagnostics.latest_turn_elapsed_seconds ?? "未知"}`, + ); + sections.push( + `- 最新回合停滞时长(秒):${diagnostics.latest_turn_stalled_seconds ?? "无"}`, + ); + sections.push( + `- 最新回合错误:${diagnostics.latest_turn_error_message || "无"}`, + ); + sections.push(`- 中断原因:${diagnostics.interrupt_reason || "未知"}`); + sections.push(`- 中断来源:${diagnostics.runtime_interrupt_source || "未知"}`); + sections.push( + `- 中断请求时间:${diagnostics.runtime_interrupt_requested_at || "未知"}`, + ); + sections.push( + `- 中断请求后等待时长(秒):${diagnostics.runtime_interrupt_wait_seconds ?? "无"}`, + ); + sections.push(`- 后端 warning 数量:${diagnostics.warning_count}`); + sections.push( + `- 后端 context compaction 数量:${diagnostics.context_compaction_count}`, + ); + sections.push( + `- 后端失败工具调用数量:${diagnostics.failed_tool_call_count}`, + ); + sections.push(`- 后端失败命令数量:${diagnostics.failed_command_count}`); + sections.push(`- 后端待处理请求数量:${diagnostics.pending_request_count}`); + sections.push( + `- 最老待处理请求等待时长(秒):${diagnostics.oldest_pending_request_wait_seconds ?? "无"}`, + ); + sections.push(`- 主阻塞类型:${diagnostics.primary_blocking_kind || "未知"}`); + sections.push(`- 主阻塞摘要:${diagnostics.primary_blocking_summary || "未知"}`); + sections.push( + `- 最近 warning:${ + diagnostics.latest_warning + ? `${diagnostics.latest_warning.code || "warning"}|${diagnostics.latest_warning.message}` + : "无" + }`, + ); + sections.push( + `- 最近 context compaction:${ + diagnostics.latest_context_compaction + ? `${diagnostics.latest_context_compaction.stage}|${diagnostics.latest_context_compaction.trigger || "未知触发"}|${diagnostics.latest_context_compaction.detail || "无详情"}` + : "无" + }`, + ); + sections.push( + `- 最近失败工具:${ + diagnostics.latest_failed_tool + ? `${diagnostics.latest_failed_tool.tool_name}|${diagnostics.latest_failed_tool.error || "无错误详情"}` + : "无" + }`, + ); + sections.push( + `- 最近失败命令:${ + diagnostics.latest_failed_command + ? `${diagnostics.latest_failed_command.command}|exit=${diagnostics.latest_failed_command.exit_code ?? "未知"}|${diagnostics.latest_failed_command.error || "无错误详情"}` + : "无" + }`, + ); + sections.push( + `- 最近待处理请求:${ + diagnostics.latest_pending_request + ? `${diagnostics.latest_pending_request.request_type}|${diagnostics.latest_pending_request.title || diagnostics.latest_pending_request.request_id}|等待 ${diagnostics.latest_pending_request.waited_seconds ?? "未知"} 秒` + : "无" + }`, + ); + } else { + sections.push("- 无"); + } + + sections.push("", "### 最近消息片段"); + if (recentMessages.length > 0) { + for (const message of recentMessages) { + sections.push( + `- ${message.role}|${message.timestamp}|${message.content_preview || "<空>"}`, + ); + } + } else { + sections.push("- 无"); + } + return sections.join("\n"); } @@ -205,9 +544,14 @@ function buildReliabilityRawPayload(params: { submittedActionsInFlight: ActionRequired[]; queuedTurns: QueuedTurnSnapshot[]; view: ReturnType; + harnessState?: HarnessSessionState | null; + messages: Message[]; + diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null; }): Record { return { exported_at: new Date().toISOString(), + runtime_context: params.diagnosticRuntimeContext || null, + backend_diagnostics: params.threadRead?.diagnostics || null, current_turn_id: params.currentTurnId || null, thread_read: params.threadRead || null, turns: params.turns, @@ -215,6 +559,9 @@ function buildReliabilityRawPayload(params: { pending_actions: params.pendingActions, submitted_actions_in_flight: params.submittedActionsInFlight, queued_turns: params.queuedTurns, + harness_state: summarizeHarnessState(params.harnessState), + recent_messages: summarizeRecentMessages(params.messages), + thread_item_signals: summarizeThreadItemSignals(params.threadItems), reliability_view: params.view, }; } @@ -236,6 +583,9 @@ export const AgentThreadReliabilityPanel: React.FC< onLocatePendingRequest, onPromoteQueuedTurn, className, + harnessState = null, + messages = [], + diagnosticRuntimeContext = null, }) => { const [isInterrupting, setIsInterrupting] = useState(false); const [isResumingThread, setIsResumingThread] = useState(false); @@ -343,9 +693,14 @@ export const AgentThreadReliabilityPanel: React.FC< try { await navigator.clipboard.writeText( buildReliabilityDiagnosticText({ + threadRead, statusLabel, summary, view, + threadItems, + messages, + harnessState, + diagnosticRuntimeContext, }), ); toast.success("AI 诊断内容已复制"); @@ -374,6 +729,9 @@ export const AgentThreadReliabilityPanel: React.FC< submittedActionsInFlight, queuedTurns, view, + harnessState, + messages, + diagnosticRuntimeContext, }), ), ); @@ -447,7 +805,7 @@ export const AgentThreadReliabilityPanel: React.FC<
- “复制给 AI” 会附带诊断任务说明;“复制原始 JSON” 适合程序化分析、存档或二次处理。 + “复制给 AI” 会附带诊断任务说明、运行环境、过程信号与最近消息;“复制原始 JSON” 适合程序化分析、存档或二次处理。
diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 4e98b22e9..6f20b8b82 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -175,7 +175,7 @@ function buildCompactReliabilityBadges( if (nextTone === "failed") { return nextTone; } - if (nextTone === "waiting" && currentTone !== "failed") { + if (nextTone === "waiting") { return nextTone; } return currentTone; diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 13f9ecf61..6db3d1f76 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -63,6 +63,7 @@ import type { ActionRequired, AgentThreadItem, AgentThreadTurn, + Message, } from "../types"; import type { HarnessFileAction, @@ -144,6 +145,16 @@ interface HarnessStatusPanelProps { onPromoteQueuedTurn?: ( queuedTurnId: string, ) => boolean | Promise; + messages?: Message[]; + diagnosticRuntimeContext?: { + sessionId?: string | null; + workspaceId?: string | null; + providerType?: string | null; + model?: string | null; + executionStrategy?: string | null; + activeTheme?: string | null; + selectedTeamLabel?: string | null; + } | null; } interface PreviewDialogState { @@ -1409,6 +1420,8 @@ export function HarnessStatusPanel({ onResumeThread, onReplayPendingRequest, onPromoteQueuedTurn, + messages = [], + diagnosticRuntimeContext = null, }: HarnessStatusPanelProps) { const [expanded, setExpanded] = useState(true); const isDialogLayout = layout === "dialog"; @@ -2327,6 +2340,9 @@ export function HarnessStatusPanel({ onResumeThread={onResumeThread} onReplayPendingRequest={onReplayPendingRequest} onPromoteQueuedTurn={onPromoteQueuedTurn} + harnessState={harnessState} + messages={messages} + diagnosticRuntimeContext={diagnosticRuntimeContext} /> ) : null} diff --git a/src/components/agent/chat/service-skills/ServiceSkillHomePanel.test.tsx b/src/components/agent/chat/service-skills/ServiceSkillHomePanel.test.tsx new file mode 100644 index 000000000..9ec366b1d --- /dev/null +++ b/src/components/agent/chat/service-skills/ServiceSkillHomePanel.test.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ServiceSkillHomePanel } from "./ServiceSkillHomePanel"; +import type { ServiceSkillHomeItem } from "./types"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderPanel( + props: React.ComponentProps, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("ServiceSkillHomePanel", () => { + it("应渲染服务型技能信息并透传选择回调", () => { + const skills: ServiceSkillHomeItem[] = [ + { + id: "short-video-script-replication", + title: "复制短视频脚本", + summary: "围绕参考视频的结构和节奏,输出一版可继续加工的脚本。", + category: "视频创作", + outputHint: "脚本大纲 + 镜头节奏", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "agent_turn", + executionLocation: "client_default", + slotSchema: [], + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "本地即时执行", + runnerTone: "emerald", + runnerDescription: "客户端起步版可直接进入工作区执行。", + actionLabel: "填写参数", + }, + ]; + const onSelect = vi.fn(); + + const container = renderPanel({ + skills, + onSelect, + }); + + expect(container.textContent).toContain("服务型技能"); + expect(container.textContent).toContain("复制短视频脚本"); + expect(container.textContent).toContain("产出:脚本大纲 + 镜头节奏"); + expect(container.textContent).toContain("本地即时执行"); + expect(container.textContent).toContain("填写参数"); + + const button = container.querySelector( + '[data-testid="service-skill-short-video-script-replication"]', + ) as HTMLButtonElement | null; + + expect(button).toBeTruthy(); + + act(() => { + button?.click(); + }); + + expect(onSelect).toHaveBeenCalledWith(skills[0]); + }); +}); diff --git a/src/components/agent/chat/service-skills/ServiceSkillHomePanel.tsx b/src/components/agent/chat/service-skills/ServiceSkillHomePanel.tsx new file mode 100644 index 000000000..61e9a772c --- /dev/null +++ b/src/components/agent/chat/service-skills/ServiceSkillHomePanel.tsx @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions"; +import type { ServiceSkillHomeItem } from "./types"; + +interface ServiceSkillHomePanelProps { + skills: ServiceSkillHomeItem[]; + loading?: boolean; + onSelect: (skill: ServiceSkillHomeItem) => void | Promise; +} + +export function ServiceSkillHomePanel({ + skills, + loading = false, + onSelect, +}: ServiceSkillHomePanelProps) { + const items = useMemo( + () => + skills.map((skill) => ({ + key: skill.id, + title: skill.title, + description: skill.summary, + badge: skill.badge, + prompt: "", + actionLabel: skill.actionLabel, + outputHint: skill.outputHint, + statusLabel: skill.runnerLabel, + statusTone: skill.runnerTone, + statusDescription: skill.runnerDescription, + testId: `service-skill-${skill.id}`, + })), + [skills], + ); + + return ( + { + const skill = skills.find((candidate) => candidate.id === item.key); + if (skill) { + void onSelect(skill); + } + }} + /> + ); +} + +export default ServiceSkillHomePanel; diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx new file mode 100644 index 000000000..9cefa3c43 --- /dev/null +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx @@ -0,0 +1,265 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ServiceSkillLaunchDialog } from "./ServiceSkillLaunchDialog"; +import type { ServiceSkillHomeItem } from "./types"; + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + open, + children, + }: { + open: boolean; + children: React.ReactNode; + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => ( + + ), +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +vi.mock("@/components/ui/textarea", () => ({ + Textarea: (props: React.TextareaHTMLAttributes) => ( +