From 0b535507bb54d901514bc53eda4cf1f85e801727 Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 12 Apr 2026 12:48:28 +0800 Subject: [PATCH] chore: release v1.8.0 --- RELEASE_NOTES.md | 16 +- docs/aiprompts/commands.md | 3 + docs/aiprompts/playwright-e2e.md | 9 + docs/aiprompts/quality-workflow.md | 2 + package-lock.json | 4 +- package.json | 2 +- packages/lime-cli-npm/README.md | 2 +- packages/lime-cli-npm/package.json | 2 +- scripts/verify-gui-smoke.mjs | 2 + src-tauri/Cargo.lock | 38 +- src-tauri/Cargo.toml | 5 +- .../crates/aster/src/providers/toolshim.rs | 44 + src-tauri/src/app/runner.rs | 93 +- .../commands/aster_agent_cmd/command_api.rs | 23 +- .../command_api/provider_api.rs | 1 + .../command_api/runtime_api.rs | 357 +- src-tauri/src/commands/aster_agent_cmd/dto.rs | 53 + src-tauri/src/commands/aster_agent_cmd/mod.rs | 149 +- .../provider_runtime_bootstrap.rs | 332 ++ .../provider_runtime_strategy.rs | 4 +- .../commands/aster_agent_cmd/runtime_turn.rs | 135 +- .../aster_agent_cmd/subagent_runtime.rs | 74 +- .../src/commands/memory_management_cmd.rs | 702 ++++ .../dev_bridge/dispatcher/memory_runtime.rs | 49 + .../src/services/agent_timeline_service.rs | 145 +- .../runtime_analysis_handoff_service.rs | 1 + .../services/runtime_evidence_pack_service.rs | 1 + .../runtime_handoff_artifact_service.rs | 1 + .../services/runtime_replay_case_service.rs | 1 + .../runtime_review_decision_service.rs | 1 + src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/AppSidebar.test.tsx | 20 +- .../agent/chat/AgentChatWorkspace.tsx | 26 +- .../chat/components/AgentRuntimeStrip.tsx | 4 +- .../AgentThreadMemoryPrefetchPreview.tsx | 329 ++ .../AgentThreadReliabilityPanel.test.tsx | 397 +- .../AgentThreadReliabilityPanel.tsx | 644 ++- .../components/CanvasWorkbenchLayout.test.tsx | 3 + .../chat/components/CanvasWorkbenchLayout.tsx | 74 +- .../chat/components/ChatSidebar.test.tsx | 130 +- .../agent/chat/components/ChatSidebar.tsx | 281 +- .../agent/chat/components/EmptyState.test.tsx | 217 +- .../agent/chat/components/EmptyState.tsx | 424 +- .../EmptyStateComposerPanel.test.tsx | 16 +- .../GeneralWorkbenchSidebar.test.tsx | 64 +- .../GeneralWorkbenchSidebarShell.tsx | 23 +- .../GeneralWorkbenchWorkflowPanel.tsx | 110 +- .../components/HarnessStatusPanel.test.tsx | 23 +- .../chat/components/HarnessStatusPanel.tsx | 312 +- .../components/InputbarComposerSection.tsx | 24 + .../InputbarWorkflowStatusPanel.tsx | 655 +-- .../Inputbar/components/TeamSelector.tsx | 4 +- .../components/TeamSelectorPanel.test.tsx | 18 +- .../Inputbar/components/TeamSelectorPanel.tsx | 76 +- .../Inputbar/hooks/useInputbarController.ts | 12 + .../chat/components/Inputbar/index.test.tsx | 72 +- .../agent/chat/components/Inputbar/index.tsx | 12 + .../chat/components/MarkdownRenderer.test.tsx | 47 + .../chat/components/MarkdownRenderer.tsx | 64 +- .../chat/components/MessageList.test.tsx | 11 +- .../agent/chat/components/MessageList.tsx | 10 +- .../agent/chat/components/README.md | 27 + .../chat/components/TeamSuggestionBar.tsx | 6 +- .../components/TeamWorkspaceBoard.test.tsx | 22 +- .../chat/components/TeamWorkspaceBoard.tsx | 2484 +---------- .../components/TeamWorkspaceDock.test.tsx | 38 +- .../chat/components/TeamWorkspaceDock.tsx | 25 +- .../SelectedSessionInlineActivitySection.tsx | 98 + ...ectedSessionInlineCollaborationSection.tsx | 122 + .../SelectedSessionInlineDetail.tsx | 274 +- .../SelectedSessionInlineHeader.tsx | 126 + .../TeamWorkspaceBoardCanvasSection.tsx | 60 + .../TeamWorkspaceBoardHeader.tsx | 3 +- .../TeamWorkspaceBoardShell.tsx | 53 + ...eamWorkspaceCanvasSelectedInlineDetail.tsx | 12 + .../TeamWorkspaceCanvasStage.tsx | 8 +- .../TeamWorkspaceCanvasToolbar.tsx | 72 + .../TeamWorkspaceEmptyShellState.tsx | 10 +- .../TeamWorkspaceFallbackDetailSection.tsx | 79 + .../TeamWorkspaceFormationPanels.tsx | 2 +- .../TeamWorkspaceTeamOperationsPanel.tsx | 151 + .../TeamWorkspaceTeamOverviewChrome.tsx | 279 +- .../TeamWorkspaceTeamOverviewControls.tsx | 142 + .../teamWorkspaceBoardPropBuilders.ts | 273 ++ .../teamWorkspaceCanvasControllerState.ts | 101 + .../useTeamWorkspaceActivityPreviewSync.ts | 244 ++ .../useTeamWorkspaceActivityPreviews.test.tsx | 234 ++ .../useTeamWorkspaceActivityPreviews.ts | 76 + .../useTeamWorkspaceBoardActions.test.tsx | 177 + .../useTeamWorkspaceBoardActions.ts | 191 + .../useTeamWorkspaceBoardCanvasRuntime.ts | 96 + .../useTeamWorkspaceBoardComposer.ts | 169 + .../useTeamWorkspaceBoardFormationState.ts | 70 + .../useTeamWorkspaceBoardPresentation.tsx | 230 + .../useTeamWorkspaceBoardRuntimeState.ts | 223 + ...TeamWorkspaceBoardSelectedInlineDetail.tsx | 100 + .../useTeamWorkspaceBoardSessionGraph.ts | 215 + .../useTeamWorkspaceBoardShellProps.ts | 254 ++ .../useTeamWorkspaceCanvasController.test.tsx | 225 + .../useTeamWorkspaceCanvasController.ts | 235 ++ ...eTeamWorkspaceCanvasInteractionHandlers.ts | 376 ++ ...useTeamWorkspaceCanvasKeyboardShortcuts.ts | 155 + .../useTeamWorkspaceSessionFocus.test.tsx | 202 + .../useTeamWorkspaceSessionFocus.ts | 126 + src/components/agent/chat/index.test.tsx | 195 +- .../service-skills/serviceSkillLaunchA2UI.ts | 6 +- .../skill-selection/CharacterMention.test.tsx | 4 +- .../skill-selection/CharacterMentionPanel.tsx | 12 +- .../boardChromeSelectors.test.ts | 12 +- .../boardChromeSelectors.ts | 11 +- .../canvasLaneSelectors.ts | 8 +- .../canvasLayoutSelectors.test.ts | 225 + .../canvasLayoutSelectors.ts | 228 + .../formationDisplaySelectors.test.ts | 10 +- .../formationDisplaySelectors.ts | 38 +- .../sessionStateSelectors.test.ts | 86 + .../sessionStateSelectors.ts | 72 +- .../agent/chat/teamWorkspaceRuntime.test.ts | 4 +- .../agent/chat/teamWorkspaceRuntime.ts | 12 +- .../chat/utils/entryRecommendedSolutions.ts | 27 +- .../agent/chat/utils/messageArtifacts.test.ts | 29 + .../agent/chat/utils/messageArtifacts.ts | 25 +- .../chat/utils/taskFileCanvasSync.test.ts | 28 + .../agent/chat/utils/taskFileCanvasSync.ts | 9 + .../chat/utils/teamWorkspaceCanvas.test.ts | 50 + .../agent/chat/utils/teamWorkspaceCanvas.ts | 210 + .../agent/chat/utils/teamWorkspaceCopy.ts | 14 +- .../chat/utils/teamWorkspaceSessions.test.ts | 275 ++ .../agent/chat/utils/teamWorkspaceSessions.ts | 476 +++ .../agent/chat/utils/workflowInputState.ts | 67 +- .../utils/workflowStepPresentation.test.ts | 73 + .../chat/utils/workflowStepPresentation.ts | 121 + .../ServiceSkillExecutionCard.test.tsx | 20 +- .../workspace/ServiceSkillExecutionCard.tsx | 55 +- .../workspace/WorkspaceHarnessDialogs.tsx | 4 + .../chat/workspace/generalWorkbenchHelpers.ts | 7 +- .../chat/workspace/resultFilePriority.ts | 65 + .../chat/workspace/runtimeTeamPreview.ts | 22 +- .../chat/workspace/sceneSkillGate.test.ts | 7 +- .../agent/chat/workspace/sceneSkillGate.ts | 8 +- .../serviceSkillResultFileTarget.test.ts | 34 +- .../workspace/serviceSkillResultFileTarget.ts | 100 +- ...seWorkspaceArtifactPreviewActions.test.tsx | 105 + .../useWorkspaceArtifactPreviewActions.ts | 8 +- .../useWorkspaceInputbarSceneRuntime.tsx | 21 +- .../useWorkspaceNavigationActions.ts | 26 +- .../workspace/useWorkspaceSceneGateRuntime.ts | 4 +- ...WorkspaceServiceSkillEntryActions.test.tsx | 10 +- .../useWorkspaceServiceSkillEntryActions.ts | 2 +- .../chat/workspace/workspaceFilePathMatch.ts | 37 + src/components/memory/MemoryPage.test.tsx | 728 +++- src/components/memory/MemoryPage.tsx | 3691 ++++++++--------- .../memory/memoryLayerMetrics.test.ts | 177 +- src/components/memory/memoryLayerMetrics.ts | 105 +- .../general/appearance/index.test.tsx | 29 +- .../settings-v2/general/appearance/index.tsx | 4 - .../settings-v2/general/memory/index.test.tsx | 84 +- .../settings-v2/general/memory/index.tsx | 89 +- .../settings-v2/system/about/index.test.tsx | 6 +- .../skills/SkillsWorkspacePage.test.tsx | 52 +- src/components/skills/SkillsWorkspacePage.tsx | 102 +- src/lib/api/agentRuntime/types.ts | 10 + src/lib/api/memoryRuntime.test.ts | 62 +- src/lib/api/memoryRuntime.ts | 35 + src/lib/api/memoryRuntimeTypes.ts | 80 + src/lib/dev-bridge/mockPriorityCommands.ts | 3 + src/lib/governance/agentCommandCatalog.json | 9 + src/lib/markdown/markdownBundleMeta.test.ts | 58 + src/lib/markdown/markdownBundleMeta.ts | 141 + src/lib/navigation/sidebarNav.ts | 7 +- src/lib/runtimeMemoryPrefetchHistory.test.ts | 582 +++ src/lib/runtimeMemoryPrefetchHistory.ts | 643 +++ src/lib/tauri-event.test.ts | 136 + src/lib/tauri-event.ts | 74 +- src/lib/tauri-mock/core.ts | 97 + src/lib/teamMemorySync.test.ts | 36 + src/lib/teamMemorySync.ts | 38 + src/types/page.ts | 8 + 179 files changed, 17681 insertions(+), 6689 deletions(-) create mode 100644 src-tauri/src/commands/aster_agent_cmd/provider_runtime_bootstrap.rs create mode 100644 src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineActivitySection.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineCollaborationSection.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineHeader.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardCanvasSection.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardShell.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasSelectedInlineDetail.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasToolbar.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceFallbackDetailSection.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOperationsPanel.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewControls.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/teamWorkspaceCanvasControllerState.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceActivityPreviewSync.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceActivityPreviews.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceActivityPreviews.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActions.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActions.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardFormationState.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardPresentation.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedInlineDetail.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSessionGraph.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceCanvasController.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceCanvasController.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceCanvasInteractionHandlers.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceCanvasKeyboardShortcuts.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceSessionFocus.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceSessionFocus.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/canvasLayoutSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/canvasLayoutSelectors.ts create mode 100644 src/components/agent/chat/utils/teamWorkspaceCanvas.test.ts create mode 100644 src/components/agent/chat/utils/teamWorkspaceSessions.test.ts create mode 100644 src/components/agent/chat/utils/teamWorkspaceSessions.ts create mode 100644 src/components/agent/chat/utils/workflowStepPresentation.test.ts create mode 100644 src/components/agent/chat/utils/workflowStepPresentation.ts create mode 100644 src/components/agent/chat/workspace/resultFilePriority.ts create mode 100644 src/components/agent/chat/workspace/workspaceFilePathMatch.ts create mode 100644 src/lib/markdown/markdownBundleMeta.test.ts create mode 100644 src/lib/markdown/markdownBundleMeta.ts create mode 100644 src/lib/runtimeMemoryPrefetchHistory.test.ts create mode 100644 src/lib/runtimeMemoryPrefetchHistory.ts create mode 100644 src/lib/tauri-event.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 488f7e140..ebc5522b7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ -## Lime v1.7.0 +## Lime v1.8.0 ### ✨ 主要更新 -- 本次 `v1.7.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链 +- 本次 `v1.8.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链 - `src/components/agent/**`、`src/components/workspace/**`、`src/lib/api/**`、`src/features/browser-runtime/**`、`src/components/settings-v2/**` 一批界面、运行时与回归测试已一并进入本次发布 - 浏览器运行时、现有会话桥接、工具展示、团队协作、项目选择、技能目录、工作台工具命令与内容同步相关边界已同步更新 - 工程文档 `docs/aiprompts/commands.md`、`playwright-e2e.md`、`quality-workflow.md` 已随当前实现一起更新 @@ -10,14 +10,14 @@ ### 🔗 依赖与版本同步 - `aster-core` / `aster-models` 已内置到 `src-tauri/crates/aster-rust/`,不再依赖外部仓库本地 override -- 应用与 CLI 发布版本提升到 `1.7.0` -- 应用版本入口已对齐到 `1.7.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` -- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.7.0` +- 应用与 CLI 发布版本提升到 `1.8.0` +- 应用版本入口已对齐到 `1.8.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.8.0` - `src-tauri/Cargo.lock` 已在本轮 Rust 校验后同步更新到当前 workspace 状态 ### ⚠️ 发布说明 -- 本次发布 tag 为 `v1.7.0` +- 本次发布 tag 为 `v1.8.0` - 本次发布以当前工作区完整改动为准,不复用旧 tag - 当前 release note 已按这次完整发布内容刷新 @@ -31,8 +31,8 @@ ### 📝 文档同步 -- 发布说明已更新为当前这次完整的 `v1.7.0` 内容,可直接作为 GitHub Release note 使用 +- 发布说明已更新为当前这次完整的 `v1.8.0` 内容,可直接作为 GitHub Release note 使用 --- -**完整变更**: `v1.6.1` -> `v1.7.0` +**完整变更**: `v1.7.0` -> `v1.8.0` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index b2c00a2bd..b07a8d265 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -528,6 +528,9 @@ npm run verify:local - **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!` - **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入 - **记忆系统**:统一沉淀优先走 `unified_memory_*`,runtime / 上下文视图优先走 `memory_runtime_*` +- **工作记忆主链**:会话级计划 / 发现 / 进度 / 错误文件继续收敛到 `memory_runtime_get_working_memory`;不要让页面、Hook 或运行时各自重新扫描 `.lime/memory` +- **记忆抽取状态主链**:记忆抽取与上下文压缩状态继续收敛到 `memory_runtime_get_extraction_status`;Memory 页面、诊断视图和后续 GUI 提示都不应各自拼凑“最近是否压缩过” +- **单回合记忆预取主链**:运行时 working / durable / compaction recall 继续收敛到 `memory_runtime_prefetch_for_turn`;不要把 working memory、统一记忆检索或压缩摘要再拆成第二套 prompt 拼装边界 - **旧项目风格命令**:`style_guide_get` / `style_guide_update` 已下线,不应再从前端网关、Rust 注册或 mock 中接回 - **旧项目模板命令**:`create_template` / `list_templates` / `get_template` / `update_template` / `delete_template` / `set_default_template` / `get_default_template` 已下线,不应再从前端网关、Rust 注册或 mock 中接回 - **旧品牌人设扩展命令**:`get_brand_persona` / `get_brand_extension` / `save_brand_extension` / `update_brand_extension` / `delete_brand_extension` / `list_brand_persona_templates` 已下线,不应再从前端网关、Rust 注册或 mock 中接回 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index eb1162bd9..740ee6aa8 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -127,6 +127,15 @@ npm run test:contracts 3. 验证主导航可见,例如“首页”“社媒内容”“设置” 4. 检查控制台 error 是否为 0 +### 记忆工作台验证 + +1. 从左侧导航进入 `灵感库` +2. 确认页面左侧已经收敛为 `总览 / 规则 / 工作记忆 / 长期记忆 / Team 影子 / 压缩边界` 六区,而不是继续把 `identity/context/preference/experience/activity` 平铺成顶层导航 +3. 打开 `规则`,确认能看到有效来源与自动记忆入口,不会把 working memory 混在同一区块 +4. 打开 `工作记忆`,确认至少能看到 session 级文件摘要或明确空态,不会直接报错 +5. 打开 `长期记忆`,确认旧 category 仍作为内部筛选存在,并且 `带回创作输入` 能跳回 `Claw` / 新建任务主链 +6. 打开 `Team 影子` 与 `压缩边界`,确认页面能消费 localStorage shadow 与 compaction 快照,不会退回旧的单层“灵感库”文案 + ### AI 服务商页拆分后验证 1. 进入 `设置 -> AI 服务商` diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 075826e22..817f20d33 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -89,6 +89,8 @@ 如果本轮涉及 `create_skill_scaffold_for_app`、`SkillsPage / SkillScaffoldDialog`,或“聊天结果 -> Skill 脚手架”沉淀闭环,还要同步检查前端网关、Rust 模板、DevBridge 分发与默认 mock 是否仍保持同一条主链;若新增了结构化骨架字段,至少要确认 `何时使用 / 输入 / 执行步骤 / 输出 / 失败回退` 能真实落进生成后的 `SKILL.md`。 +如果本轮涉及记忆主链,还要同步检查 `src/lib/api/memoryRuntime.ts`、`src-tauri/src/commands/memory_management_cmd.rs`、`runner.rs`、DevBridge dispatcher 与默认 mock 是否仍保持同一条 current surface;`rules / working / durable / team / compaction` 的产品分层可以在页面上拆开,但底层命令边界仍必须继续收敛到 `memory_runtime_*` 与 `unified_memory_*`。 + ### 3. 用户可见 UI 改动必须补稳定回归 - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 diff --git a/package-lock.json b/package-lock.json index f3970b090..fa538cf7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.7.0", + "version": "1.8.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index faccbf4fa..30ec03229 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.7.0", + "version": "1.8.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index a65c135a8..6b28c14c7 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.7.0" \ + --version "1.8.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 3fedfb257..140bb0425 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.7.0", + "version": "1.8.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 484d16c1a..d4422e814 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -19,6 +19,7 @@ const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const LIME_SKIP_STARTUP_WINDOW_REVEAL = "LIME_SKIP_STARTUP_WINDOW_REVEAL"; +const LIME_DISABLE_SINGLE_INSTANCE = "LIME_DISABLE_SINGLE_INSTANCE"; const state = { child: null, @@ -167,6 +168,7 @@ function startHeadlessTauri() { env: { ...process.env, [LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1", + [LIME_DISABLE_SINGLE_INSTANCE]: "1", }, detached: process.platform !== "win32", }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 14382f636..b4fa7d487 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.7.0" +version = "1.8.0" dependencies = [ "anyhow", "arboard", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.7.0" +version = "1.8.0" dependencies = [ "anyhow", "aster-core", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.7.0" +version = "1.8.0" dependencies = [ "chrono", "futures", @@ -5252,7 +5252,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.7.0" +version = "1.8.0" dependencies = [ "clap", "lime-core", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.7.0" +version = "1.8.0" dependencies = [ "async-trait", "lime-core", @@ -5280,7 +5280,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.7.0" +version = "1.8.0" dependencies = [ "aster-models", "async-trait", @@ -5320,7 +5320,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.7.0" +version = "1.8.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.7.0" +version = "1.8.0" dependencies = [ "aes", "axum 0.7.9", @@ -5385,7 +5385,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.7.0" +version = "1.8.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5405,7 +5405,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.7.0" +version = "1.8.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5421,7 +5421,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.7.0" +version = "1.8.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.7.0" +version = "1.8.0" dependencies = [ "async-trait", "lime-core", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.7.0" +version = "1.8.0" dependencies = [ "anyhow", "async-stream", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.7.0" +version = "1.8.0" dependencies = [ "aster-core", "async-stream", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.7.0" +version = "1.8.0" dependencies = [ "axum 0.7.9", "futures", @@ -5586,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.7.0" +version = "1.8.0" dependencies = [ "anyhow", "aster-core", @@ -5628,7 +5628,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.7.0" +version = "1.8.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.7.0" +version = "1.8.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.7.0" +version = "1.8.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1506121dc..34eee9fd5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.7.0" +version = "1.8.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -189,7 +189,7 @@ version = "2.4" [package] name = "lime" -version = "1.7.0" +version = "1.8.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -199,6 +199,7 @@ homepage = "https://github.com/aiclientproxy/lime" [lib] name = "lime_lib" crate-type = ["lib", "cdylib", "staticlib"] +doctest = false [build-dependencies] tauri-build.workspace = true diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/toolshim.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/toolshim.rs index ef77fdbc1..b4e88b1d1 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/toolshim.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/toolshim.rs @@ -246,6 +246,28 @@ fn normalize_interpreter_model_override(interpreter_model: Option) -> Op .filter(|value| !value.is_empty()) } +fn should_attempt_tool_interpretation(content: &str) -> bool { + let trimmed = content.trim(); + if trimmed.is_empty() { + return false; + } + + let lowered = trimmed.to_ascii_lowercase(); + trimmed.starts_with('{') + || trimmed.starts_with('[') + || trimmed.starts_with("```") + || lowered.contains("\"tool_calls\"") + || lowered.contains("\"name\"") + || lowered.contains("\"arguments\"") + || lowered.contains("tool_calls") + || lowered.contains("tool call") + || lowered.contains("use tool") + || lowered.contains("using tool") + || lowered.contains("调用工具") + || lowered.contains("使用工具") + || lowered.contains("工具调用") +} + fn resolve_interpreter_model(explicit_model: Option<&str>) -> String { explicit_model .map(str::trim) @@ -433,6 +455,11 @@ pub async fn augment_message_with_tool_calls( None => return Ok(message), }; + // 普通自然语言增量不需要进入 toolshim 解释器,否则会被每个文本片段重复放大延迟。 + if !should_attempt_tool_interpretation(content) { + return Ok(message); + } + // Check if there's already a tool request if message .content @@ -501,4 +528,21 @@ mod tests { assert_eq!(resolved, DEFAULT_INTERPRETER_MODEL_OLLAMA); } + + #[test] + fn plain_text_reply_does_not_trigger_tool_interpretation() { + assert!(!should_attempt_tool_interpretation("测试通过")); + assert!(!should_attempt_tool_interpretation("好的,我来处理。")); + } + + #[test] + fn json_like_reply_still_triggers_tool_interpretation() { + assert!(should_attempt_tool_interpretation( + r#"{"name":"write_file","arguments":{"path":"demo.txt"}}"# + )); + assert!(should_attempt_tool_interpretation( + "```json\n{\"tool_calls\":[{\"name\":\"noop\",\"arguments\":{}}]}\n```" + )); + assert!(should_attempt_tool_interpretation("我准备调用工具")); + } } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index b39d2732d..1db9707ff 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -17,6 +17,7 @@ use super::types::{AppState, TrayManagerState}; const MAIN_WINDOW_LABEL: &str = "main"; const SKIP_STARTUP_WINDOW_REVEAL_ENV: &str = "LIME_SKIP_STARTUP_WINDOW_REVEAL"; +const DISABLE_SINGLE_INSTANCE_ENV: &str = "LIME_DISABLE_SINGLE_INSTANCE"; fn env_flag_enabled(key: &str) -> bool { matches!( @@ -34,6 +35,10 @@ fn should_reveal_main_window_on_startup() -> bool { !env_flag_enabled(SKIP_STARTUP_WINDOW_REVEAL_ENV) } +fn should_enable_single_instance() -> bool { + !env_flag_enabled(DISABLE_SINGLE_INSTANCE_ENV) +} + fn compiled_updater_public_key() -> Option<&'static str> { option_env!("LIME_UPDATER_PUBLIC_KEY") .map(str::trim) @@ -169,39 +174,43 @@ pub fn run() { builder = builder.plugin(tauri_plugin_deep_link::init()); } - builder = builder - // 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例 - .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args); + if should_enable_single_instance() { + builder = builder + // 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例 + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args); - // 将窗口带到前台 - if should_reveal_main_window_on_startup() { - if let Some(window) = app.get_webview_window("main") { - reveal_main_window(&window); - } - } else { - tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)"); - } - - let deep_link_urls: Vec = args - .iter() - .filter_map(|arg| { - let value = arg.trim(); - if value.starts_with("lime://") { - Some(value.to_string()) - } else { - None + // 将窗口带到前台 + if should_reveal_main_window_on_startup() { + if let Some(window) = app.get_webview_window("main") { + reveal_main_window(&window); } - }) - .collect(); - - if !deep_link_urls.is_empty() { - tracing::info!("[单实例] 转发 Deep Link URL: {:?}", deep_link_urls); - if let Err(error) = app.emit("deep-link://new-url", &deep_link_urls) { - tracing::error!("[单实例] 转发 Deep Link URL 失败: {}", error); + } else { + tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)"); } - } - })); + + let deep_link_urls: Vec = args + .iter() + .filter_map(|arg| { + let value = arg.trim(); + if value.starts_with("lime://") { + Some(value.to_string()) + } else { + None + } + }) + .collect(); + + if !deep_link_urls.is_empty() { + tracing::info!("[单实例] 转发 Deep Link URL: {:?}", deep_link_urls); + if let Err(error) = app.emit("deep-link://new-url", &deep_link_urls) { + tracing::error!("[单实例] 转发 Deep Link URL 失败: {}", error); + } + } + })); + } else { + tracing::info!("[启动] 已禁用单实例插件(当前会话允许并行实例)"); + } builder .manage(state) @@ -1755,6 +1764,9 @@ pub fn run() { commands::memory_management_cmd::memory_runtime_get_overview, commands::memory_management_cmd::memory_runtime_request_analysis, commands::memory_management_cmd::memory_runtime_cleanup, + commands::memory_management_cmd::memory_runtime_get_working_memory, + commands::memory_management_cmd::memory_runtime_get_extraction_status, + commands::memory_management_cmd::memory_runtime_prefetch_for_turn, commands::memory_management_cmd::memory_get_effective_sources, commands::memory_management_cmd::memory_get_auto_index, commands::memory_management_cmd::memory_toggle_auto, @@ -1856,7 +1868,9 @@ pub fn run() { #[cfg(test)] mod tests { - use super::should_minimize_to_tray; + use super::{ + should_enable_single_instance, should_minimize_to_tray, DISABLE_SINGLE_INSTANCE_ENV, + }; #[test] fn should_only_minimize_main_window_to_tray() { @@ -1864,4 +1878,21 @@ mod tests { assert!(!should_minimize_to_tray("openclaw-dashboard", true)); assert!(!should_minimize_to_tray("main", false)); } + + #[test] + fn should_allow_disabling_single_instance_via_env() { + unsafe { + std::env::remove_var(DISABLE_SINGLE_INSTANCE_ENV); + } + assert!(should_enable_single_instance()); + + unsafe { + std::env::set_var(DISABLE_SINGLE_INSTANCE_ENV, "1"); + } + assert!(!should_enable_single_instance()); + + unsafe { + std::env::remove_var(DISABLE_SINGLE_INSTANCE_ENV); + } + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api.rs index 1c3e16352..ae300a75d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api.rs @@ -1,5 +1,4 @@ use super::*; -use crate::commands::aster_agent_cmd::runtime_turn::build_runtime_queue_executor; use crate::commands::aster_agent_cmd::session_runtime::{ create_runtime_session_internal, list_runtime_sessions_internal, rename_runtime_session_internal, update_runtime_session_execution_strategy_internal, @@ -20,6 +19,28 @@ pub(crate) mod session_api; #[path = "command_api/subagent_api.rs"] pub(crate) mod subagent_api; +fn build_runtime_command_context( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, +) -> RuntimeCommandContext { + RuntimeCommandContext::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ) +} + fn build_subagent_control_runtime( app: AppHandle, state: State<'_, AsterAgentState>, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs index bdcbb94cb..14cc539fe 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs @@ -42,6 +42,7 @@ pub async fn aster_agent_configure_provider( mut request: ConfigureProviderRequest, session_id: String, ) -> Result { + ensure_provider_runtime_ready(&request).await?; let runtime_tool_call_decision = enrich_provider_config_with_runtime_tool_strategy(&mut request).await; tracing::info!( 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 57315b91c..58867f57c 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 @@ -18,6 +18,24 @@ use crate::services::runtime_review_decision_service::{ use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection; use std::path::PathBuf; +async fn resume_runtime_queue_with_warning( + runtime: &RuntimeCommandContext, + session_id: &str, + action_label: &str, +) { + if let Err(error) = runtime + .resume_runtime_queue_if_needed(session_id.to_string()) + .await + { + tracing::warn!( + "[AsterAgent][Queue] {}恢复排队执行失败: session_id={}, error={}", + action_label, + session_id, + error + ); + } +} + #[tauri::command] pub async fn agent_runtime_submit_turn( app: AppHandle, @@ -30,23 +48,22 @@ pub async fn agent_runtime_submit_turn( automation_state: State<'_, AutomationServiceState>, request: AgentRuntimeSubmitTurnRequest, ) -> Result<(), String> { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); let runtime_request: AsterChatRequest = request.into(); let queue_if_busy = runtime_request.queue_if_busy.unwrap_or(false); let queued_task = build_queued_turn_task(runtime_request)?; - submit_runtime_turn_service( - app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - queued_task, - queue_if_busy, - build_runtime_queue_executor(), - ) - .await + runtime + .submit_runtime_turn(queued_task, queue_if_busy) + .await } /// 统一运行时:中断当前 turn。 @@ -97,24 +114,22 @@ pub async fn agent_runtime_resume_thread( automation_state: State<'_, AutomationServiceState>, request: AgentRuntimeResumeThreadRequest, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); let session_id = request.session_id.trim().to_string(); if session_id.is_empty() { return Ok(false); } - resume_runtime_queue_if_needed_service( - app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - session_id, - build_runtime_queue_executor(), - ) - .await + runtime.resume_runtime_queue_if_needed(session_id).await } /// 统一运行时:获取会话详情。 @@ -130,32 +145,23 @@ pub async fn agent_runtime_get_session( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { - tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id); - if let Err(error) = resume_runtime_queue_if_needed_service( + let runtime = build_runtime_command_context( app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - session_id.clone(), - build_runtime_queue_executor(), - ) - .await - { - tracing::warn!( - "[AsterAgent][Queue] 获取会话后恢复排队执行失败: session_id={}, error={}", - session_id, - error - ); - } + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); + tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id); + resume_runtime_queue_with_warning(&runtime, &session_id, "获取会话后").await; - let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; + let detail = AsterAgentWrapper::get_runtime_session_detail(runtime.db(), &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 projection = sync_thread_reliability_projection(runtime.db(), &detail)?; + let interrupt_marker = runtime.state().get_interrupt_marker(&session_id).await; let thread_read = AgentRuntimeThreadReadModel::from_parts( &detail, &queued_turns, @@ -186,32 +192,23 @@ pub async fn agent_runtime_get_thread_read( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { - tracing::info!("[AsterAgent] 获取运行时线程读模型: {}", session_id); - if let Err(error) = resume_runtime_queue_if_needed_service( + let runtime = build_runtime_command_context( app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - session_id.clone(), - build_runtime_queue_executor(), - ) - .await - { - tracing::warn!( - "[AsterAgent][Queue] 获取线程读模型后恢复排队执行失败: session_id={}, error={}", - session_id, - error - ); - } + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); + tracing::info!("[AsterAgent] 获取运行时线程读模型: {}", session_id); + resume_runtime_queue_with_warning(&runtime, &session_id, "获取线程读模型后").await; - let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; + let detail = AsterAgentWrapper::get_runtime_session_detail(runtime.db(), &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 projection = sync_thread_reliability_projection(runtime.db(), &detail)?; + let interrupt_marker = runtime.state().get_interrupt_marker(&session_id).await; Ok(AgentRuntimeThreadReadModel::from_parts( &detail, &queued_turns, @@ -261,43 +258,16 @@ fn resolve_runtime_export_workspace_root( } async fn load_runtime_export_context( - app: &AppHandle, - state: &AsterAgentState, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - logs: &LogState, - config_manager: &GlobalConfigManagerState, - mcp_manager: &McpManagerState, - automation_state: &AutomationServiceState, + runtime: &RuntimeCommandContext, session_id: &str, action_label: &str, ) -> Result { - if let Err(error) = resume_runtime_queue_if_needed_service( - app.clone(), - state, - db, - api_key_provider_service, - logs, - config_manager, - mcp_manager, - automation_state, - session_id.to_string(), - build_runtime_queue_executor(), - ) - .await - { - tracing::warn!( - "[AsterAgent][Queue] {} 前恢复排队执行失败: session_id={}, error={}", - action_label, - session_id, - error - ); - } + resume_runtime_queue_with_warning(runtime, session_id, action_label).await; - let detail = AsterAgentWrapper::get_runtime_session_detail(db, session_id).await?; + let detail = AsterAgentWrapper::get_runtime_session_detail(runtime.db(), session_id).await?; let queued_turns = list_runtime_queue_snapshots_service(session_id).await?; - let projection = sync_thread_reliability_projection(db, &detail)?; - let interrupt_marker = state.get_interrupt_marker(session_id).await; + let projection = sync_thread_reliability_projection(runtime.db(), &detail)?; + let interrupt_marker = runtime.state().get_interrupt_marker(session_id).await; let thread_read = AgentRuntimeThreadReadModel::from_parts( &detail, &queued_turns, @@ -306,7 +276,7 @@ async fn load_runtime_export_context( projection.incidents, interrupt_marker.as_ref(), ); - let workspace_root = resolve_runtime_export_workspace_root(db, &detail)?; + let workspace_root = resolve_runtime_export_workspace_root(runtime.db(), &detail)?; Ok(RuntimeExportContext { detail, @@ -328,20 +298,19 @@ pub async fn agent_runtime_export_handoff_bundle( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); tracing::info!("[AsterAgent] 导出 handoff bundle: {}", session_id); - let context = load_runtime_export_context( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - &session_id, - "导出 handoff bundle", - ) - .await?; + let context = + load_runtime_export_context(&runtime, &session_id, "导出 handoff bundle 前").await?; export_runtime_handoff_bundle( &context.detail, @@ -363,20 +332,19 @@ pub async fn agent_runtime_export_evidence_pack( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); tracing::info!("[AsterAgent] 导出 evidence pack: {}", session_id); - let context = load_runtime_export_context( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - &session_id, - "导出 evidence pack", - ) - .await?; + let context = + load_runtime_export_context(&runtime, &session_id, "导出 evidence pack 前").await?; export_runtime_evidence_pack( &context.detail, @@ -398,20 +366,19 @@ pub async fn agent_runtime_export_analysis_handoff( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); tracing::info!("[AsterAgent] 导出 analysis handoff: {}", session_id); - let context = load_runtime_export_context( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - &session_id, - "导出 analysis handoff", - ) - .await?; + let context = + load_runtime_export_context(&runtime, &session_id, "导出 analysis handoff 前").await?; export_runtime_analysis_handoff( &context.detail, @@ -433,20 +400,19 @@ pub async fn agent_runtime_export_review_decision_template( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); tracing::info!("[AsterAgent] 导出 review decision 模板: {}", session_id); - let context = load_runtime_export_context( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - &session_id, - "导出 review decision 模板", - ) - .await?; + let context = + load_runtime_export_context(&runtime, &session_id, "导出 review decision 模板前").await?; export_runtime_review_decision_template( &context.detail, @@ -468,21 +434,20 @@ pub async fn agent_runtime_save_review_decision( automation_state: State<'_, AutomationServiceState>, request: AgentRuntimeSaveReviewDecisionRequest, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); let session_id = request.session_id.trim().to_string(); tracing::info!("[AsterAgent] 保存 review decision: {}", session_id); - let context = load_runtime_export_context( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - &session_id, - "保存 review decision", - ) - .await?; + let context = + load_runtime_export_context(&runtime, &session_id, "保存 review decision 前").await?; save_runtime_review_decision( &context.detail, @@ -516,20 +481,18 @@ pub async fn agent_runtime_export_replay_case( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); tracing::info!("[AsterAgent] 导出 replay case: {}", session_id); - let context = load_runtime_export_context( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - &session_id, - "导出 replay case", - ) - .await?; + let context = load_runtime_export_context(&runtime, &session_id, "导出 replay case 前").await?; export_runtime_replay_case( &context.detail, @@ -697,6 +660,16 @@ pub async fn agent_runtime_promote_queued_turn( automation_state: State<'_, AutomationServiceState>, request: AgentRuntimePromoteQueuedTurnRequest, ) -> Result { + let runtime = build_runtime_command_context( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); let session_id = request.session_id.trim().to_string(); let queued_turn_id = request.queued_turn_id.trim().to_string(); if session_id.is_empty() || queued_turn_id.is_empty() { @@ -708,20 +681,8 @@ pub async fn agent_runtime_promote_queued_turn( return Ok(false); } - let _ = state.cancel_session(&session_id).await; - let _ = resume_runtime_queue_if_needed_service( - app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - session_id, - build_runtime_queue_executor(), - ) - .await?; + let _ = runtime.state().cancel_session(&session_id).await; + let _ = runtime.resume_runtime_queue_if_needed(session_id).await?; Ok(true) } diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 54dcfe0ca..a904ce1ac 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -411,6 +411,19 @@ pub struct AgentRuntimeDiagnosticPendingRequestSample { pub created_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeCompactionBoundarySnapshot { + pub session_id: String, + pub summary_preview: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_count: Option, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentRuntimeThreadDiagnostics { #[serde(skip_serializing_if = "Option::is_none")] @@ -477,6 +490,8 @@ pub struct AgentRuntimeThreadReadModel { #[serde(skip_serializing_if = "Option::is_none")] pub updated_at: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub latest_compaction_boundary: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub diagnostics: Option, } @@ -628,6 +643,12 @@ impl AgentRuntimeThreadReadModel { &incidents, runtime_interrupt_marker, ); + let latest_compaction_boundary = build_latest_compaction_boundary_snapshot( + &detail.id, + diagnostics + .as_ref() + .and_then(|value| value.latest_context_compaction.as_ref()), + ); let active_turn = detail .turns .iter() @@ -691,6 +712,7 @@ impl AgentRuntimeThreadReadModel { updated_at: latest_turn .map(|turn| turn.updated_at.clone()) .or_else(|| Some(detail.updated_at.to_string())), + latest_compaction_boundary, diagnostics, } } @@ -717,6 +739,37 @@ fn elapsed_seconds_between(start_raw: Option<&str>, end_raw: Option<&str>) -> Op Some(end.signed_duration_since(start).num_seconds().max(0)) } +fn compact_summary_preview(input: &str, max_chars: usize) -> String { + let normalized = input + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" | "); + let mut chars = normalized.chars(); + let prefix: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{prefix}...") + } else { + prefix + } +} + +fn build_latest_compaction_boundary_snapshot( + session_id: &str, + latest_context_compaction: Option<&AgentRuntimeDiagnosticContextCompactionSample>, +) -> Option { + let summary = aster::session::load_summary_data(session_id)?; + Some(AgentRuntimeCompactionBoundarySnapshot { + session_id: session_id.to_string(), + summary_preview: compact_summary_preview(&summary.summary, 220), + turn_count: summary.turn_count.map(|value| value as u32), + created_at: summary.timestamp.to_rfc3339(), + trigger: latest_context_compaction.and_then(|sample| sample.trigger.clone()), + detail: latest_context_compaction.and_then(|sample| sample.detail.clone()), + }) +} + fn warning_sample_from_item( item: &lime_core::database::dao::agent_timeline::AgentThreadItem, ) -> Option { diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index a546afe5c..ed43a1972 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -270,6 +270,7 @@ mod mcp_bridge; mod pdf_read_skill_launch; mod presentation_skill_launch; mod prompt_context; +mod provider_runtime_bootstrap; mod provider_runtime_strategy; mod reply_runtime; mod report_skill_launch; @@ -402,6 +403,7 @@ pub(crate) use prompt_context::{ merge_system_prompt_with_service_skill_launch_preload, merge_system_prompt_with_team_preference, }; +pub(crate) use provider_runtime_bootstrap::ensure_provider_runtime_ready; pub(crate) use provider_runtime_strategy::{ enrich_provider_config_with_runtime_tool_strategy, RuntimeToolCallStrategy, }; @@ -524,6 +526,149 @@ pub(crate) use webpage_skill_launch::{ prune_webpage_skill_launch_detour_tools_from_registry, }; +pub(crate) struct RuntimeCommandContext { + app_handle: AppHandle, + state: AsterAgentState, + db: DbConnection, + api_key_provider_service: ApiKeyProviderServiceState, + logs: LogState, + config_manager: GlobalConfigManagerState, + mcp_manager: McpManagerState, + automation_state: AutomationServiceState, + runtime_queue_executor: RuntimeQueueExecutor, +} + +impl std::fmt::Debug for RuntimeCommandContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RuntimeCommandContext") + .field("app_handle", &"") + .field("state", &"") + .field("db", &"") + .field("api_key_provider_service", &"") + .field("logs", &"") + .field("config_manager", &"") + .field("mcp_manager", &"") + .field("automation_state", &"") + .field("runtime_queue_executor", &"") + .finish() + } +} + +impl Clone for RuntimeCommandContext { + fn clone(&self) -> Self { + Self { + app_handle: self.app_handle.clone(), + state: self.state.clone(), + db: self.db.clone(), + api_key_provider_service: ApiKeyProviderServiceState( + self.api_key_provider_service.0.clone(), + ), + logs: self.logs.clone(), + config_manager: GlobalConfigManagerState(self.config_manager.0.clone()), + mcp_manager: self.mcp_manager.clone(), + automation_state: self.automation_state.clone(), + runtime_queue_executor: self.runtime_queue_executor.clone(), + } + } +} + +impl RuntimeCommandContext { + pub(crate) fn new( + app_handle: AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + ) -> Self { + Self { + app_handle, + state: state.clone(), + db: db.clone(), + api_key_provider_service: ApiKeyProviderServiceState( + api_key_provider_service.0.clone(), + ), + logs: logs.clone(), + config_manager: GlobalConfigManagerState(config_manager.0.clone()), + mcp_manager: mcp_manager.clone(), + automation_state: automation_state.clone(), + runtime_queue_executor: build_runtime_queue_executor(), + } + } + + pub(crate) fn state(&self) -> &AsterAgentState { + &self.state + } + + pub(crate) fn db(&self) -> &DbConnection { + &self.db + } + + pub(crate) async fn submit_runtime_turn( + &self, + queued_task: QueuedTurnTask, + queue_if_busy: bool, + ) -> Result<(), String> { + submit_runtime_turn_service( + self.app_handle.clone(), + &self.state, + &self.db, + &self.api_key_provider_service, + &self.logs, + &self.config_manager, + &self.mcp_manager, + &self.automation_state, + queued_task, + queue_if_busy, + self.runtime_queue_executor.clone(), + ) + .await + } + + pub(crate) async fn resume_runtime_queue_if_needed( + &self, + session_id: String, + ) -> Result { + resume_runtime_queue_if_needed_service( + self.app_handle.clone(), + &self.state, + &self.db, + &self.api_key_provider_service, + &self.logs, + &self.config_manager, + &self.mcp_manager, + &self.automation_state, + session_id, + self.runtime_queue_executor.clone(), + ) + .await + } + + pub(crate) async fn resume_persisted_runtime_queues_on_startup(&self) -> Result { + resume_persisted_runtime_queues_on_startup_service( + self.app_handle.clone(), + &self.state, + &self.db, + &self.api_key_provider_service, + &self.logs, + &self.config_manager, + &self.mcp_manager, + &self.automation_state, + self.runtime_queue_executor.clone(), + ) + .await + } + + pub(crate) async fn clear_runtime_queue( + &self, + session_id: &str, + ) -> Result, String> { + clear_runtime_queue_service(&self.app_handle, session_id).await + } +} + pub async fn resume_persisted_runtime_queues_on_startup( app: AppHandle, state: &AsterAgentState, @@ -534,7 +679,7 @@ pub async fn resume_persisted_runtime_queues_on_startup( mcp_manager: &McpManagerState, automation_state: &AutomationServiceState, ) -> Result { - resume_persisted_runtime_queues_on_startup_service( + RuntimeCommandContext::new( app, state, db, @@ -543,8 +688,8 @@ pub async fn resume_persisted_runtime_queues_on_startup( config_manager, mcp_manager, automation_state, - build_runtime_queue_executor(), ) + .resume_persisted_runtime_queues_on_startup() .await } diff --git a/src-tauri/src/commands/aster_agent_cmd/provider_runtime_bootstrap.rs b/src-tauri/src/commands/aster_agent_cmd/provider_runtime_bootstrap.rs new file mode 100644 index 000000000..b8b4edcda --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/provider_runtime_bootstrap.rs @@ -0,0 +1,332 @@ +use super::dto::ConfigureProviderRequest; +use super::provider_runtime_strategy::{is_ollama_provider, normalize_ollama_base_url}; +use async_trait::async_trait; +use std::process::Stdio; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; +use tokio::process::Command; +use tokio::sync::Mutex as AsyncMutex; +use tokio::time::sleep; +use url::Url; + +#[cfg(test)] +use std::collections::VecDeque; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(test)] +use std::sync::Mutex; + +const OLLAMA_RUNTIME_BOOTSTRAP_TIMEOUT_SECS: u64 = 20; +const OLLAMA_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS: u64 = 500; + +#[derive(Debug, Clone, Copy)] +struct RuntimeBootstrapPolicy { + startup_timeout: Duration, + poll_interval: Duration, +} + +impl Default for RuntimeBootstrapPolicy { + fn default() -> Self { + Self { + startup_timeout: Duration::from_secs(OLLAMA_RUNTIME_BOOTSTRAP_TIMEOUT_SECS), + poll_interval: Duration::from_millis(OLLAMA_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalProviderRuntimeStrategy { + Ollama, +} + +impl LocalProviderRuntimeStrategy { + fn resolve(request: &ConfigureProviderRequest) -> Option { + if is_ollama_provider(request.provider_id.as_deref(), &request.provider_name) { + return Some(Self::Ollama); + } + None + } + + async fn ensure_ready(self, request: &ConfigureProviderRequest) -> Result<(), String> { + match self { + Self::Ollama => { + ensure_ollama_runtime_ready(&normalize_ollama_base_url(request.base_url.as_deref())) + .await + } + } + } +} + +pub(crate) async fn ensure_provider_runtime_ready( + request: &ConfigureProviderRequest, +) -> Result<(), String> { + let Some(strategy) = LocalProviderRuntimeStrategy::resolve(request) else { + return Ok(()); + }; + + strategy.ensure_ready(request).await +} + +fn ollama_launch_guard() -> &'static AsyncMutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| AsyncMutex::new(())) +} + +fn should_manage_local_ollama_runtime(base_url: &str) -> bool { + let Ok(parsed) = Url::parse(base_url) else { + return false; + }; + matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "::1")) +} + +#[async_trait] +trait OllamaRuntimeHooks: Send + Sync { + async fn is_ready(&self, base_url: &str) -> bool; + async fn launch(&self) -> Result<(), String>; +} + +struct SystemOllamaRuntimeHooks; + +#[async_trait] +impl OllamaRuntimeHooks for SystemOllamaRuntimeHooks { + async fn is_ready(&self, base_url: &str) -> bool { + check_ollama_runtime_ready(base_url).await + } + + async fn launch(&self) -> Result<(), String> { + launch_ollama_runtime().await + } +} + +async fn ensure_ollama_runtime_ready(base_url: &str) -> Result<(), String> { + let hooks = SystemOllamaRuntimeHooks; + ensure_ollama_runtime_ready_with_hooks(base_url, &hooks, RuntimeBootstrapPolicy::default()) + .await +} + +async fn ensure_ollama_runtime_ready_with_hooks( + base_url: &str, + hooks: &dyn OllamaRuntimeHooks, + policy: RuntimeBootstrapPolicy, +) -> Result<(), String> { + if !should_manage_local_ollama_runtime(base_url) { + return Ok(()); + } + + if hooks.is_ready(base_url).await { + return Ok(()); + } + + let _guard = ollama_launch_guard().lock().await; + if hooks.is_ready(base_url).await { + return Ok(()); + } + + tracing::warn!( + "[AsterAgent] 检测到本地 Ollama 未就绪,尝试自动拉起: {}", + base_url + ); + hooks + .launch() + .await + .map_err(|error| format!("自动启动本地 Ollama 失败: {error}"))?; + + let deadline = Instant::now() + policy.startup_timeout; + loop { + if hooks.is_ready(base_url).await { + tracing::info!("[AsterAgent] 本地 Ollama 已就绪: {}", base_url); + return Ok(()); + } + if Instant::now() >= deadline { + break; + } + sleep(policy.poll_interval).await; + } + + Err(format!( + "本地 Ollama 未就绪,已尝试自动启动但仍无法连接:{}。请确认 Ollama.app 或 `ollama serve` 已运行。", + base_url + )) +} + +async fn check_ollama_runtime_ready(base_url: &str) -> bool { + let tags_url = format!("{}/api/tags", base_url.trim_end_matches('/')); + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .no_proxy() + .build() + else { + return false; + }; + + client + .get(tags_url) + .send() + .await + .ok() + .and_then(|response| response.error_for_status().ok()) + .is_some() +} + +#[derive(Debug, Clone)] +struct CommandCandidate { + program: &'static str, + args: &'static [&'static str], +} + +fn ollama_launch_candidates() -> Vec { + let mut candidates = vec![CommandCandidate { + program: "ollama", + args: &["serve"], + }]; + + #[cfg(target_os = "macos")] + { + candidates.push(CommandCandidate { + program: "/Applications/Ollama.app/Contents/Resources/ollama", + args: &["serve"], + }); + candidates.push(CommandCandidate { + program: "open", + args: &["-a", "Ollama"], + }); + } + + candidates +} + +async fn launch_ollama_runtime() -> Result<(), String> { + let mut errors = Vec::new(); + + for candidate in ollama_launch_candidates() { + let mut command = Command::new(candidate.program); + command + .args(candidate.args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + match command.spawn() { + Ok(child) => { + drop(child); + tracing::info!( + "[AsterAgent] 已发起本地 Ollama 启动命令: {} {:?}", + candidate.program, + candidate.args + ); + return Ok(()); + } + Err(error) => { + errors.push(format!( + "{} {:?}: {}", + candidate.program, candidate.args, error + )); + } + } + } + + Err(errors.join(" | ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeOllamaRuntimeHooks { + readiness: Mutex>, + launch_count: AtomicUsize, + launch_result: Result<(), String>, + } + + impl FakeOllamaRuntimeHooks { + fn new(readiness: Vec, launch_result: Result<(), String>) -> Self { + Self { + readiness: Mutex::new(readiness.into()), + launch_count: AtomicUsize::new(0), + launch_result, + } + } + + fn launch_count(&self) -> usize { + self.launch_count.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl OllamaRuntimeHooks for FakeOllamaRuntimeHooks { + async fn is_ready(&self, _base_url: &str) -> bool { + self.readiness + .lock() + .expect("lock readiness") + .pop_front() + .unwrap_or(false) + } + + async fn launch(&self) -> Result<(), String> { + self.launch_count.fetch_add(1, Ordering::SeqCst); + self.launch_result.clone() + } + } + + fn fast_policy() -> RuntimeBootstrapPolicy { + RuntimeBootstrapPolicy { + startup_timeout: Duration::from_millis(20), + poll_interval: Duration::from_millis(1), + } + } + + #[test] + fn should_only_manage_loopback_ollama_runtime() { + assert!(should_manage_local_ollama_runtime("http://127.0.0.1:11434")); + assert!(should_manage_local_ollama_runtime("http://localhost:11434")); + assert!(!should_manage_local_ollama_runtime( + "https://ollama.example.com" + )); + } + + #[tokio::test] + async fn ensure_should_skip_remote_ollama_host() { + let hooks = FakeOllamaRuntimeHooks::new(vec![false], Ok(())); + + ensure_ollama_runtime_ready_with_hooks("https://ollama.example.com", &hooks, fast_policy()) + .await + .expect("remote host should skip bootstrap"); + + assert_eq!(hooks.launch_count(), 0); + } + + #[tokio::test] + async fn ensure_should_not_launch_when_ollama_is_already_ready() { + let hooks = FakeOllamaRuntimeHooks::new(vec![true], Ok(())); + + ensure_ollama_runtime_ready_with_hooks("http://127.0.0.1:11434", &hooks, fast_policy()) + .await + .expect("ready ollama"); + + assert_eq!(hooks.launch_count(), 0); + } + + #[tokio::test] + async fn ensure_should_launch_once_and_wait_until_ready() { + let hooks = FakeOllamaRuntimeHooks::new(vec![false, false, true], Ok(())); + + ensure_ollama_runtime_ready_with_hooks("http://127.0.0.1:11434", &hooks, fast_policy()) + .await + .expect("ollama should become ready after launch"); + + assert_eq!(hooks.launch_count(), 1); + } + + #[tokio::test] + async fn ensure_should_fail_when_launch_does_not_make_ollama_ready() { + let hooks = FakeOllamaRuntimeHooks::new(vec![false, false, false, false], Ok(())); + + let error = + ensure_ollama_runtime_ready_with_hooks("http://127.0.0.1:11434", &hooks, fast_policy()) + .await + .expect_err("ollama should time out"); + + assert_eq!(hooks.launch_count(), 1); + assert!(error.contains("本地 Ollama 未就绪")); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/provider_runtime_strategy.rs b/src-tauri/src/commands/aster_agent_cmd/provider_runtime_strategy.rs index 8b88e9a1b..08b43d90d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/provider_runtime_strategy.rs +++ b/src-tauri/src/commands/aster_agent_cmd/provider_runtime_strategy.rs @@ -43,7 +43,7 @@ fn normalize_provider_identity(value: &str) -> String { value.trim().to_ascii_lowercase() } -fn is_ollama_provider(provider_selector: Option<&str>, provider_name: &str) -> bool { +pub(crate) fn is_ollama_provider(provider_selector: Option<&str>, provider_name: &str) -> bool { provider_selector .map(normalize_provider_identity) .or_else(|| Some(normalize_provider_identity(provider_name))) @@ -59,7 +59,7 @@ fn normalize_optional_text(value: Option<&str>) -> Option { } } -fn normalize_ollama_base_url(base_url: Option<&str>) -> String { +pub(crate) fn normalize_ollama_base_url(base_url: Option<&str>) -> String { let candidate = normalize_optional_text(base_url).unwrap_or_else(|| "http://127.0.0.1:11434".to_string()); let raw = if candidate.starts_with("http://") || candidate.starts_with("https://") { diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index dcede072a..c879ed32e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -10,9 +10,43 @@ const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_p const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_auto_internal"; const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed"; const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed"; +const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact"; +fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec) { + for event in events { + if let Err(error) = app.emit(event_name, &event) { + tracing::error!("[AsterAgent] 发送运行时事件失败: {}", error); + } + } +} + +fn merge_runtime_memory_prefetch_prompt( + base_prompt: Option, + prefetch_prompt: Option<&str>, +) -> Option { + let Some(prefetch_prompt) = prefetch_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(TURN_MEMORY_PREFETCH_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(prefetch_prompt.to_string()) + } else { + Some(format!("{base}\n\n{prefetch_prompt}")) + } + } + None => Some(prefetch_prompt.to_string()), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProviderConfigApplyMode { Direct, @@ -473,6 +507,7 @@ async fn execute_aster_chat_request( request.provider_config = Some(resolved_provider_config); } if let Some(provider_config) = request.provider_config.as_mut() { + ensure_provider_runtime_ready(provider_config).await?; let runtime_tool_call_decision = enrich_provider_config_with_runtime_tool_strategy(provider_config).await; tracing::info!( @@ -776,6 +811,50 @@ async fn execute_aster_chat_request( &runtime_config, MemoryPromptContext::with_working_dir(Path::new(&workspace_root)), ); + let prompt_with_memory = if runtime_config.memory.enabled { + let prefetch_request = crate::commands::memory_management_cmd::TurnMemoryPrefetchRequest { + session_id: session_id.to_string(), + working_dir: Some(workspace_root.clone()), + user_message: request.message.clone(), + request_metadata: request.metadata.clone(), + max_durable_entries: None, + max_working_chars: None, + }; + + match db.lock() { + Ok(conn) => { + match crate::commands::memory_management_cmd::build_turn_memory_prefetch_result( + &runtime_config, + &conn, + Path::new(&workspace_root), + &prefetch_request, + ) { + Ok(prefetch) => merge_runtime_memory_prefetch_prompt( + prompt_with_memory, + prefetch.prompt.as_deref(), + ), + Err(error) => { + tracing::warn!( + "[AsterAgent] 单回合记忆预取失败,已降级继续: session_id={}, error={}", + session_id, + error + ); + prompt_with_memory + } + } + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 记忆预取无法获取数据库锁,已降级继续: session_id={}, error={}", + session_id, + error + ); + prompt_with_memory + } + } + } else { + prompt_with_memory + }; turn_input_builder.apply_prompt_stage( TurnPromptAugmentationStageKind::Memory, prompt_with_memory.clone(), @@ -1669,15 +1748,21 @@ async fn execute_aster_chat_request( &runtime_status_session_config, ) .await; - { + let terminal_events = { let mut recorder = match timeline_recorder.lock() { Ok(guard) => guard, Err(error) => error.into_inner(), }; - if let Err(error) = recorder.complete_turn_success(app, &request.event_name) { + recorder.complete_turn_success() + }; + { + if let Err(error) = &terminal_events { tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error); } } + if let Ok(events) = terminal_events { + emit_runtime_events(app, &request.event_name, events); + } let usage = resolve_runtime_message_usage(session_id).await; if let Some(ref usage) = usage { if let Err(error) = persist_latest_assistant_message_usage(db, session_id, usage) { @@ -1703,18 +1788,24 @@ async fn execute_aster_chat_request( &runtime_status_session_config, ) .await; - { + let terminal_events = { let mut recorder = match timeline_recorder.lock() { Ok(guard) => guard, Err(error) => error.into_inner(), }; - if let Err(timeline_error) = recorder.fail_turn(app, &request.event_name, &e) { + recorder.fail_turn(&e) + }; + { + if let Err(timeline_error) = &terminal_events { tracing::warn!( "[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}", timeline_error ); } } + if let Ok(events) = terminal_events { + emit_runtime_events(app, &request.event_name, events); + } let error_event = RuntimeAgentEvent::Error { message: e.clone() }; if let Err(emit_err) = app.emit(&request.event_name, &error_event) { tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err); @@ -2121,39 +2212,51 @@ async fn compact_runtime_session_with_trigger( match final_result { Ok(()) => { - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), + let terminal_events = { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + recorder.complete_turn_success() }; - if let Err(error) = recorder.complete_turn_success(app, &event_name) { + if let Err(error) = &terminal_events { tracing::warn!( "[AsterAgent] 完成压缩 turn 时间线失败(已降级继续): {}", error ); } + if let Ok(events) = terminal_events { + emit_runtime_events(app, &event_name, events); + } let done_event = RuntimeAgentEvent::FinalDone { usage: None }; if let Err(error) = app.emit(&event_name, &done_event) { tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error); } } Err(error) => { - { + let terminal_events = { let mut recorder = match timeline_recorder.lock() { Ok(guard) => guard, Err(error) => error.into_inner(), }; - if let Err(timeline_error) = recorder.fail_turn(app, &event_name, &error) { + recorder.fail_turn(&error) + }; + { + if let Err(timeline_error) = &terminal_events { tracing::warn!( "[AsterAgent] 记录压缩失败 turn 时间线失败(已降级继续): {}", timeline_error ); } - let error_event = RuntimeAgentEvent::Error { - message: error.clone(), - }; - if let Err(emit_error) = app.emit(&event_name, &error_event) { - tracing::error!("[AsterAgent] 发送压缩错误事件失败: {}", emit_error); - } + } + if let Ok(events) = terminal_events { + emit_runtime_events(app, &event_name, events); + } + let error_event = RuntimeAgentEvent::Error { + message: error.clone(), + }; + if let Err(emit_error) = app.emit(&event_name, &error_event) { + tracing::error!("[AsterAgent] 发送压缩错误事件失败: {}", emit_error); } state.remove_cancel_token(&session_id).await; return Err(error); diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index d1dd4051d..7c56c947b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -110,6 +110,19 @@ impl SubagentControlRuntime { async fn ensure_initialized(&self) -> Result<(), String> { self.state.init_agent_with_db(&self.db).await } + + fn runtime_command_context(&self) -> RuntimeCommandContext { + RuntimeCommandContext::new( + self.app_handle.clone(), + &self.state, + &self.db, + &self.api_key_provider_service, + &self.logs, + &self.config_manager, + &self.mcp_manager, + &self.automation_state, + ) + } } fn normalize_required_text(value: &str, field_name: &str) -> Result { @@ -736,21 +749,11 @@ fn spawn_subagent_turn_in_background( ) -> Result { let queued_task = build_queued_turn_task(request)?; let submission_id = queued_task.queued_turn_id.clone(); + let runtime_command_context = runtime.runtime_command_context(); tokio::spawn(async move { - if let Err(error) = submit_runtime_turn_service( - runtime.app_handle.clone(), - &runtime.state, - &runtime.db, - &runtime.api_key_provider_service, - &runtime.logs, - &runtime.config_manager, - &runtime.mcp_manager, - &runtime.automation_state, - queued_task, - false, - build_runtime_queue_executor(), - ) - .await + if let Err(error) = runtime_command_context + .submit_runtime_turn(queued_task, false) + .await { tracing::warn!("[AsterAgent][Subagent] 后台启动子代理失败: {}", error); } @@ -835,6 +838,7 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( request: AgentRuntimeSendSubagentInputRequest, ) -> Result { runtime.ensure_initialized().await?; + let runtime_command_context = runtime.runtime_command_context(); let session_id = normalize_required_text(&request.id, "id")?; let message = normalize_required_text(&request.message, "message")?; let status = load_subagent_runtime_status(&session_id).await?; @@ -853,7 +857,9 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?; if request.interrupt { let _ = runtime.state.cancel_session(&session_id).await; - let _ = clear_runtime_queue_service(&runtime.app_handle, &session_id).await?; + let _ = runtime_command_context + .clear_runtime_queue(&session_id) + .await?; } let workspace_id = @@ -897,20 +903,9 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( queued_turn_id: None, })?; let submission_id = queued_task.queued_turn_id.clone(); - submit_runtime_turn_service( - runtime.app_handle.clone(), - &runtime.state, - &runtime.db, - &runtime.api_key_provider_service, - &runtime.logs, - &runtime.config_manager, - &runtime.mcp_manager, - &runtime.automation_state, - queued_task, - true, - build_runtime_queue_executor(), - ) - .await?; + runtime_command_context + .submit_runtime_turn(queued_task, true) + .await?; emit_subagent_status_changed_events(&runtime.app_handle, &session_id).await; Ok(AgentRuntimeSendSubagentInputResponse { submission_id }) @@ -961,6 +956,7 @@ pub(crate) async fn agent_runtime_resume_subagent_internal( request: AgentRuntimeResumeSubagentRequest, ) -> Result { runtime.ensure_initialized().await?; + let runtime_command_context = runtime.runtime_command_context(); let session_id = normalize_required_text(&request.id, "id")?; let current_status = load_subagent_runtime_status(&session_id).await?; if current_status.kind == SubagentRuntimeStatusKind::NotFound @@ -988,19 +984,9 @@ pub(crate) async fn agent_runtime_resume_subagent_internal( write_subagent_control_state(&session, &next_state).await?; restore_stashed_subagent_queue(stashed_queued_turns.clone()).await?; if !stashed_queued_turns.is_empty() { - let _ = resume_runtime_queue_if_needed_service( - runtime.app_handle.clone(), - &runtime.state, - &runtime.db, - &runtime.api_key_provider_service, - &runtime.logs, - &runtime.config_manager, - &runtime.mcp_manager, - &runtime.automation_state, - target_id.clone(), - build_runtime_queue_executor(), - ) - .await?; + let _ = runtime_command_context + .resume_runtime_queue_if_needed(target_id.clone()) + .await?; } changed_ids.push(target_id); } @@ -1021,6 +1007,7 @@ pub(crate) async fn agent_runtime_close_subagent_internal( request: AgentRuntimeCloseSubagentRequest, ) -> Result { runtime.ensure_initialized().await?; + let runtime_command_context = runtime.runtime_command_context(); let session_id = normalize_required_text(&request.id, "id")?; let previous_status = load_subagent_runtime_status(&session_id).await?; if matches!( @@ -1044,7 +1031,8 @@ pub(crate) async fn agent_runtime_close_subagent_internal( } let _ = runtime.state.cancel_session(&target_id).await; - let cleared_queued_turns = clear_runtime_queue_service(&runtime.app_handle, &target_id) + let cleared_queued_turns = runtime_command_context + .clear_runtime_queue(&target_id) .await .unwrap_or_default(); let next_state = SubagentControlState::closed( diff --git a/src-tauri/src/commands/memory_management_cmd.rs b/src-tauri/src/commands/memory_management_cmd.rs index d760c6349..06ca118eb 100644 --- a/src-tauri/src/commands/memory_management_cmd.rs +++ b/src-tauri/src/commands/memory_management_cmd.rs @@ -8,6 +8,7 @@ //! 属于当前仍在演进的记忆治理配置入口 use crate::commands::context_memory::ContextMemoryServiceState; +use crate::commands::unified_memory_cmd::{list_unified_memories, ListFilters}; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; use crate::services::auto_memory_service::{ @@ -22,6 +23,7 @@ use crate::services::runtime_agents_template_service::{ RuntimeAgentsTemplateScaffoldResult, RuntimeAgentsTemplateTarget, WorkspaceGitignoreEnsureResult, }; +use aster::session::list_summaries; use chrono::{Local, NaiveDateTime, TimeZone}; use lime_core::app_paths; use lime_services::context_memory_service::{MemoryEntry, MemoryFileType}; @@ -102,6 +104,124 @@ pub struct MemoryAutoToggleResponse { pub enabled: bool, } +/// 工作记忆文件摘要 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkingMemoryFileSummary { + pub file_type: String, + pub path: String, + pub exists: bool, + pub entry_count: u32, + pub updated_at: i64, + pub summary: String, +} + +/// 单个会话的工作记忆视图 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkingMemorySessionSummary { + pub session_id: String, + pub total_entries: u32, + pub updated_at: i64, + pub files: Vec, + pub highlights: Vec, +} + +/// 工作记忆总览 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkingMemoryView { + pub memory_dir: String, + pub total_sessions: u32, + pub total_entries: u32, + pub sessions: Vec, +} + +/// 压缩边界快照 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompactionBoundarySnapshot { + pub session_id: String, + pub source: String, + pub summary_preview: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_count: Option, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// 工作记忆抽取状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryExtractionStatusResponse { + pub enabled: bool, + pub status: String, + pub status_summary: String, + pub working_session_count: u32, + pub working_entry_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_working_memory_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_compaction: Option, + #[serde(default)] + pub recent_compactions: Vec, +} + +/// 持久记忆召回条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DurableMemoryRecallEntry { + pub id: String, + pub session_id: String, + pub category: String, + pub title: String, + pub summary: String, + pub updated_at: i64, + pub tags: Vec, +} + +/// Team 影子记忆条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeamMemoryShadowEntry { + pub key: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +/// 单回合记忆预取请求 +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TurnMemoryPrefetchRequest { + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub working_dir: Option, + #[serde(default)] + pub user_message: String, + #[serde(default)] + pub request_metadata: Option, + #[serde(default)] + pub max_durable_entries: Option, + #[serde(default)] + pub max_working_chars: Option, +} + +/// 单回合记忆预取结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TurnMemoryPrefetchResult { + pub session_id: String, + #[serde(default)] + pub rules_source_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_memory_excerpt: Option, + #[serde(default)] + pub durable_memories: Vec, + #[serde(default)] + pub team_memory_entries: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_compaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, +} + #[derive(Debug, Clone, Deserialize, Default)] struct ErrorEntryRecord { #[serde(default)] @@ -138,6 +258,11 @@ const MAX_GENERATED_PER_REQUEST: usize = 200; const MAX_GENERATED_PER_REQUEST_CAP: usize = 2000; const MAX_GENERATED_PER_SESSION: usize = 40; const MIN_MESSAGE_LENGTH: usize = 18; +const DEFAULT_WORKING_SESSION_LIMIT: usize = 24; +const MAX_WORKING_SESSION_LIMIT: usize = 120; +const DEFAULT_RECENT_COMPACTION_LIMIT: usize = 12; +const DEFAULT_PREFETCH_DURABLE_LIMIT: usize = 5; +const DEFAULT_PREFETCH_WORKING_CHARS: usize = 2400; async fn memory_runtime_get_stats_impl() -> Result { info!("[记忆管理] 获取记忆统计信息"); @@ -447,6 +572,85 @@ pub async fn memory_ensure_workspace_local_agents_gitignore( ensure_workspace_local_agents_gitignore(&resolved_working_dir) } +/// 获取结构化工作记忆视图 +#[tauri::command] +pub async fn memory_runtime_get_working_memory( + session_id: Option, + limit: Option, +) -> Result { + let memory_dir = resolve_memory_dir(); + let normalized_limit = limit + .map(|value| value as usize) + .unwrap_or(DEFAULT_WORKING_SESSION_LIMIT) + .clamp(1, MAX_WORKING_SESSION_LIMIT); + collect_working_memory_view(&memory_dir, session_id.as_deref(), normalized_limit) +} + +/// 获取记忆抽取与压缩状态 +#[tauri::command] +pub async fn memory_runtime_get_extraction_status( + global_config: State<'_, GlobalConfigManagerState>, +) -> Result { + let config = global_config.config(); + let memory_dir = resolve_memory_dir(); + let working_view = + collect_working_memory_view(&memory_dir, None, DEFAULT_WORKING_SESSION_LIMIT)?; + let recent_compactions = list_recent_compactions(DEFAULT_RECENT_COMPACTION_LIMIT); + let latest_working_memory_at = working_view + .sessions + .iter() + .map(|session| session.updated_at) + .filter(|timestamp| *timestamp > 0) + .max(); + let latest_compaction = recent_compactions.first().cloned(); + + let (status, status_summary) = if !config.memory.enabled { + ( + "disabled".to_string(), + "全局记忆功能当前已关闭,规则、工作记忆与自动沉淀都不会参与运行时。".to_string(), + ) + } else if working_view.total_entries == 0 && latest_compaction.is_none() { + ( + "idle".to_string(), + "还没有检测到工作记忆文件或上下文压缩结果,当前处于冷启动状态。".to_string(), + ) + } else if working_view.total_entries > 0 && latest_compaction.is_none() { + ( + "collecting".to_string(), + "已检测到工作记忆文件,当前还没有可复用的上下文压缩快照。".to_string(), + ) + } else { + ( + "ready".to_string(), + "工作记忆和上下文压缩快照都已就绪,可用于运行时回忆与续接。".to_string(), + ) + }; + + Ok(MemoryExtractionStatusResponse { + enabled: config.memory.enabled, + status, + status_summary, + working_session_count: working_view.total_sessions, + working_entry_count: working_view.total_entries, + latest_working_memory_at, + latest_compaction, + recent_compactions, + }) +} + +/// 为单回合构建记忆预取结果 +#[tauri::command] +pub async fn memory_runtime_prefetch_for_turn( + global_config: State<'_, GlobalConfigManagerState>, + db: State<'_, DbConnection>, + request: TurnMemoryPrefetchRequest, +) -> Result { + let config = global_config.config(); + let resolved_working_dir = resolve_working_dir(request.working_dir.clone())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + build_turn_memory_prefetch_result(&config, &conn, &resolved_working_dir, &request) +} + fn resolve_memory_dir() -> PathBuf { app_paths::best_effort_runtime_subdir("memory") } @@ -580,6 +784,504 @@ fn collect_memory_overview(memory_dir: &Path) -> Result, + files: HashMap, +} + +fn collect_working_memory_view( + memory_dir: &Path, + session_id: Option<&str>, + limit: usize, +) -> Result { + let overview = collect_memory_overview(memory_dir)?; + let mut sessions: HashMap = HashMap::new(); + + for entry in overview.entries.iter() { + if session_id.is_some_and(|value| value.trim() != entry.session_id) { + continue; + } + + let accumulator = sessions + .entry(entry.session_id.clone()) + .or_insert_with(WorkingMemorySessionAccumulator::default); + accumulator.total_entries += 1; + accumulator.updated_at = accumulator.updated_at.max(entry.updated_at); + accumulator.highlights.push(entry.clone()); + + let file_path = memory_dir + .join(&entry.session_id) + .join(memory_file_name_by_file_type(&entry.file_type)); + let file_summary = accumulator + .files + .entry(entry.file_type.clone()) + .or_insert_with(|| WorkingMemoryFileSummary { + file_type: entry.file_type.clone(), + path: file_path.to_string_lossy().to_string(), + exists: file_path.exists(), + entry_count: 0, + updated_at: 0, + summary: String::new(), + }); + file_summary.entry_count += 1; + file_summary.updated_at = file_summary.updated_at.max(entry.updated_at); + if file_summary.summary.is_empty() { + file_summary.summary = entry.summary.clone(); + } else if !file_summary.summary.contains(&entry.title) { + file_summary.summary = + truncate_text(&format!("{} / {}", file_summary.summary, entry.title), 140); + } + } + + let mut session_summaries = sessions + .into_iter() + .map(|(current_session_id, mut accumulator)| { + accumulator.highlights.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)) + }); + accumulator.highlights.truncate(5); + + let mut files = accumulator.files.into_values().collect::>(); + files.sort_by(|left, right| { + working_file_sort_key(&left.file_type) + .cmp(&working_file_sort_key(&right.file_type)) + .then_with(|| left.file_type.cmp(&right.file_type)) + }); + + WorkingMemorySessionSummary { + session_id: current_session_id, + total_entries: accumulator.total_entries, + updated_at: accumulator.updated_at, + files, + highlights: accumulator.highlights, + } + }) + .collect::>(); + + session_summaries.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + session_summaries.truncate(limit); + + let total_entries = session_summaries + .iter() + .map(|session| session.total_entries) + .sum(); + + Ok(WorkingMemoryView { + memory_dir: memory_dir.to_string_lossy().to_string(), + total_sessions: session_summaries.len() as u32, + total_entries, + sessions: session_summaries, + }) +} + +fn memory_file_name_by_file_type(file_type: &str) -> &'static str { + match file_type { + "task_plan" => "task_plan.md", + "findings" => "findings.md", + "progress" => "progress.md", + "error_log" => "error_log.json", + _ => "unknown", + } +} + +fn working_file_sort_key(file_type: &str) -> usize { + match file_type { + "task_plan" => 0, + "findings" => 1, + "progress" => 2, + "error_log" => 3, + _ => 99, + } +} + +fn build_compaction_boundary_snapshot( + session_id: &str, + summary_text: &str, + created_at: i64, + turn_count: Option, + trigger: Option, + detail: Option, +) -> CompactionBoundarySnapshot { + CompactionBoundarySnapshot { + session_id: session_id.to_string(), + source: "summary_cache".to_string(), + summary_preview: compact_preview(summary_text, 220), + turn_count: turn_count.map(|value| value as u32), + created_at, + trigger, + detail, + } +} + +fn list_recent_compactions(limit: usize) -> Vec { + list_summaries() + .into_iter() + .take(limit) + .map(|summary| { + build_compaction_boundary_snapshot( + &summary.uuid, + &summary.summary, + summary.timestamp.timestamp_millis(), + summary.turn_count, + None, + None, + ) + }) + .collect() +} + +pub(crate) fn build_turn_memory_prefetch_result( + config: &lime_core::config::Config, + conn: &Connection, + working_dir: &Path, + request: &TurnMemoryPrefetchRequest, +) -> Result { + let memory_dir = resolve_memory_dir(); + let session_id = request.session_id.trim().to_string(); + let max_durable_entries = request + .max_durable_entries + .unwrap_or(DEFAULT_PREFETCH_DURABLE_LIMIT) + .clamp(1, 12); + let max_working_chars = request + .max_working_chars + .unwrap_or(DEFAULT_PREFETCH_WORKING_CHARS) + .clamp(400, 12_000); + let resolution = resolve_effective_sources(config, working_dir, None); + let rules_source_paths = resolution + .response + .sources + .iter() + .filter(|source| source.loaded) + .map(|source| source.path.clone()) + .collect::>(); + let working_memory_excerpt = if session_id.is_empty() { + None + } else { + load_working_memory_excerpt(&memory_dir, &session_id, max_working_chars) + }; + let durable_memories = resolve_durable_memory_recall( + conn, + &session_id, + &request.user_message, + max_durable_entries, + )?; + let team_memory_entries = extract_team_memory_shadow_entries(request.request_metadata.as_ref()); + let latest_compaction = list_recent_compactions(DEFAULT_RECENT_COMPACTION_LIMIT) + .into_iter() + .find(|snapshot| snapshot.session_id == session_id); + let prompt = build_turn_memory_prefetch_prompt( + working_memory_excerpt.as_deref(), + &durable_memories, + latest_compaction.as_ref(), + ); + + Ok(TurnMemoryPrefetchResult { + session_id, + rules_source_paths, + working_memory_excerpt, + durable_memories, + team_memory_entries, + latest_compaction, + prompt, + }) +} + +fn load_working_memory_excerpt( + memory_dir: &Path, + session_id: &str, + max_chars: usize, +) -> Option { + let session_dir = memory_dir.join(session_id); + if !session_dir.exists() { + return None; + } + + let mut sections = Vec::new(); + for file_name in SUPPORTED_MEMORY_FILES { + let file_path = session_dir.join(file_name); + if !file_path.exists() { + continue; + } + + let raw = match fs::read_to_string(&file_path) { + Ok(content) => content, + Err(_) => continue, + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + let rendered = if file_name == "error_log.json" { + render_error_log_excerpt(trimmed) + } else { + compact_preview(trimmed, max_chars / 2) + }; + if rendered.is_empty() { + continue; + } + + sections.push(format!("【{}】\n{}", file_name, rendered)); + } + + if sections.is_empty() { + None + } else { + Some(truncate_text(§ions.join("\n\n"), max_chars)) + } +} + +fn render_error_log_excerpt(content: &str) -> String { + parse_error_entries("session", content) + .into_iter() + .take(3) + .map(|entry| format!("- {}:{}", entry.title, entry.summary)) + .collect::>() + .join("\n") +} + +fn compact_preview(input: &str, max_chars: usize) -> String { + let normalized = input + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" | "); + truncate_text(&normalized, max_chars) +} + +fn resolve_durable_memory_recall( + conn: &Connection, + session_id: &str, + user_message: &str, + limit: usize, +) -> Result, String> { + let trimmed_query = user_message.trim(); + let mut memories = if !trimmed_query.is_empty() { + search_durable_memories(conn, trimmed_query, limit)? + } else { + Vec::new() + }; + + if memories.is_empty() && !session_id.trim().is_empty() { + let session_memories = list_unified_memories( + conn, + ListFilters { + session_id: Some(session_id.to_string()), + limit: Some(limit), + ..Default::default() + }, + )?; + memories = session_memories + .into_iter() + .map(map_durable_memory_recall_entry) + .collect(); + } + + if memories.is_empty() { + let recent_memories = list_unified_memories( + conn, + ListFilters { + limit: Some(limit), + ..Default::default() + }, + )?; + memories = recent_memories + .into_iter() + .map(map_durable_memory_recall_entry) + .collect(); + } + + memories.truncate(limit); + Ok(memories) +} + +fn search_durable_memories( + conn: &Connection, + query: &str, + limit: usize, +) -> Result, String> { + let search_pattern = format!("%{}%", escape_like_for_sqlite(query)); + let mut stmt = conn + .prepare( + "SELECT id, session_id, category, title, summary, tags, updated_at + FROM unified_memory + WHERE archived = 0 + AND (title LIKE ? ESCAPE '\\\\' OR summary LIKE ? ESCAPE '\\\\' OR content LIKE ? ESCAPE '\\\\') + ORDER BY updated_at DESC + LIMIT ?", + ) + .map_err(|e| format!("构建持久记忆搜索失败: {e}"))?; + + let rows = stmt + .query_map( + rusqlite::params![search_pattern, search_pattern, search_pattern, limit as i64], + |row| { + let tags_raw: String = row.get(5)?; + let tags = serde_json::from_str::>(&tags_raw).unwrap_or_default(); + Ok(DurableMemoryRecallEntry { + id: row.get(0)?, + session_id: row.get(1)?, + category: normalize_category_value_for_storage(&row.get::<_, String>(2)?), + title: row.get(3)?, + summary: row.get(4)?, + tags, + updated_at: row.get(6)?, + }) + }, + ) + .map_err(|e| format!("执行持久记忆搜索失败: {e}"))?; + + rows.collect::, rusqlite::Error>>() + .map_err(|e| format!("解析持久记忆搜索结果失败: {e}")) +} + +fn normalize_category_value_for_storage(value: &str) -> String { + serde_json::from_str::(value).unwrap_or_else(|_| value.to_string()) +} + +fn map_durable_memory_recall_entry(memory: lime_memory::UnifiedMemory) -> DurableMemoryRecallEntry { + DurableMemoryRecallEntry { + id: memory.id, + session_id: memory.session_id, + category: serde_json::to_string(&memory.category) + .ok() + .and_then(|encoded| serde_json::from_str::(&encoded).ok()) + .unwrap_or_else(|| "context".to_string()), + title: memory.title, + summary: memory.summary, + updated_at: memory.updated_at, + tags: memory.tags, + } +} + +fn extract_team_memory_shadow_entries( + request_metadata: Option<&serde_json::Value>, +) -> Vec { + let Some(metadata_object) = request_metadata.and_then(serde_json::Value::as_object) else { + return Vec::new(); + }; + let harness_object = metadata_object + .get("harness") + .and_then(serde_json::Value::as_object); + let shadow = harness_object + .and_then(|object| { + object + .get("team_memory_shadow") + .or_else(|| object.get("teamMemoryShadow")) + }) + .or_else(|| { + metadata_object + .get("team_memory_shadow") + .or_else(|| metadata_object.get("teamMemoryShadow")) + }) + .and_then(serde_json::Value::as_object); + let Some(shadow_object) = shadow else { + return Vec::new(); + }; + let Some(entries) = shadow_object + .get("entries") + .and_then(serde_json::Value::as_array) + else { + return Vec::new(); + }; + + entries + .iter() + .filter_map(|entry| { + let object = entry.as_object()?; + let key = object + .get("key") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let content = object + .get("content") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let updated_at = object + .get("updated_at") + .or_else(|| object.get("updatedAt")) + .and_then(serde_json::Value::as_i64); + Some(TeamMemoryShadowEntry { + key: key.to_string(), + content: compact_preview(content, 220), + updated_at, + }) + }) + .collect() +} + +fn build_turn_memory_prefetch_prompt( + working_memory_excerpt: Option<&str>, + durable_memories: &[DurableMemoryRecallEntry], + latest_compaction: Option<&CompactionBoundarySnapshot>, +) -> Option { + let mut lines = vec!["【运行时记忆召回】".to_string()]; + + if let Some(excerpt) = working_memory_excerpt.filter(|value| !value.trim().is_empty()) { + lines.push( + "- 以下是当前会话最近沉淀下来的工作记忆,只用于帮助你续接上下文,不要逐字复述给用户。" + .to_string(), + ); + lines.push(excerpt.to_string()); + } + + if !durable_memories.is_empty() { + lines.push( + "- 以下是与当前输入最接近的长期结构化记忆,请优先当作可复用事实,而不是重新臆测。" + .to_string(), + ); + for memory in durable_memories { + let tag_suffix = if memory.tags.is_empty() { + String::new() + } else { + format!(" / tags: {}", memory.tags.join(", ")) + }; + lines.push(format!( + " - [{}] {}:{}{}", + memory.category, memory.title, memory.summary, tag_suffix + )); + } + } + + if let Some(compaction) = latest_compaction { + lines.push( + "- 以下是最近一次上下文压缩后的边界摘要;如果当前对话需要追溯更早历史,优先从这里续接。" + .to_string(), + ); + lines.push(format!( + " - session={} / turns={:?} / summary={}", + compaction.session_id, compaction.turn_count, compaction.summary_preview + )); + } + + if lines.len() == 1 { + None + } else { + Some(lines.join("\n")) + } +} + +fn escape_like_for_sqlite(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + fn parse_memory_file(session_id: &str, file_name: &str, content: &str) -> Vec { match file_name { "task_plan.md" => parse_markdown_entries(session_id, content, "task_plan"), diff --git a/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs b/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs index 4b30efbd8..c619b119c 100644 --- a/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs +++ b/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs @@ -63,6 +63,55 @@ pub(super) async fn try_handle( .map_err(|e| format!("清理记忆失败: {e}"))?, )? } + "memory_runtime_get_working_memory" => { + let args = args_or_default(args); + let session_id = args + .get("sessionId") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as u32); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_get_working_memory( + session_id, limit, + ) + .await + .map_err(|e| format!("获取工作记忆失败: {e}"))?, + )? + } + "memory_runtime_get_extraction_status" => { + let app_handle = require_app_handle(state)?; + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_get_extraction_status( + global_config, + ) + .await + .map_err(|e| format!("获取记忆抽取状态失败: {e}"))?, + )? + } + "memory_runtime_prefetch_for_turn" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let request = serde_json::from_value( + args.get("request") + .cloned() + .ok_or_else(|| "缺少参数: request".to_string())?, + )?; + let global_config = app_handle.state::(); + let db = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_prefetch_for_turn( + global_config, + db, + request, + ) + .await + .map_err(|e| format!("预取单回合记忆失败: {e}"))?, + )? + } "memory_get_effective_sources" => { let app_handle = require_app_handle(state)?; let args = args_or_default(args); diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index a03edd42d..d05a19ce3 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -235,12 +235,7 @@ impl AgentTimelineRecorder { Ok(()) } - pub fn complete_turn_success( - &mut self, - app: &AppHandle, - event_name: &str, - ) -> Result<(), String> { - self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?; + pub fn complete_turn_success(&mut self) -> Result, String> { let now = Utc::now().to_rfc3339(); self.turn.status = AgentThreadTurnStatus::Completed; self.turn.completed_at = Some(now.clone()); @@ -258,33 +253,14 @@ impl AgentTimelineRecorder { .map_err(|e| format!("更新 turn 完成状态失败: {e}"))?; drop(conn); - emit_event( - app, - event_name, - &RuntimeAgentEvent::TurnCompleted { - turn: self.turn.clone(), - }, - ); - Ok(()) + let mut events = self.complete_projection_items(AgentThreadItemStatus::Completed)?; + events.push(RuntimeAgentEvent::TurnCompleted { + turn: self.turn.clone(), + }); + Ok(events) } - pub fn fail_turn( - &mut self, - app: &AppHandle, - event_name: &str, - message: &str, - ) -> Result<(), String> { - self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?; - let error_item = self.build_item( - format!("error:{}", self.turn_id), - AgentThreadItemStatus::Failed, - Some(Utc::now().to_rfc3339()), - AgentThreadItemPayload::Error { - message: message.to_string(), - }, - ); - self.persist_and_emit_item(app, event_name, error_item)?; - + pub fn fail_turn(&mut self, message: &str) -> Result, String> { let now = Utc::now().to_rfc3339(); self.turn.status = AgentThreadTurnStatus::Failed; self.turn.completed_at = Some(now.clone()); @@ -303,22 +279,27 @@ impl AgentTimelineRecorder { .map_err(|e| format!("更新 turn 失败状态失败: {e}"))?; drop(conn); - emit_event( - app, - event_name, - &RuntimeAgentEvent::TurnFailed { - turn: self.turn.clone(), + let mut events = self.complete_projection_items(AgentThreadItemStatus::Completed)?; + let error_item = self.build_item( + format!("error:{}", self.turn_id), + AgentThreadItemStatus::Failed, + Some(Utc::now().to_rfc3339()), + AgentThreadItemPayload::Error { + message: message.to_string(), }, ); - Ok(()) + events.push(self.persist_item_and_build_event(error_item)?); + events.push(RuntimeAgentEvent::TurnFailed { + turn: self.turn.clone(), + }); + Ok(events) } fn complete_projection_items( &mut self, - app: &AppHandle, - event_name: &str, status: AgentThreadItemStatus, - ) -> Result<(), String> { + ) -> Result, String> { + let mut events = Vec::new(); if let Some(plan_text) = self.plan_text.clone() { let item = self.build_item( format!("plan:{}", self.turn_id), @@ -326,10 +307,10 @@ impl AgentTimelineRecorder { Some(Utc::now().to_rfc3339()), AgentThreadItemPayload::Plan { text: plan_text }, ); - self.persist_and_emit_item(app, event_name, item)?; + events.push(self.persist_item_and_build_event(item)?); } - Ok(()) + Ok(events) } fn build_item( @@ -381,6 +362,15 @@ impl AgentTimelineRecorder { event_name: &str, item: AgentThreadItem, ) -> Result<(), String> { + let event = self.persist_item_and_build_event(item)?; + emit_event(app, event_name, &event); + Ok(()) + } + + fn persist_item_and_build_event( + &mut self, + item: AgentThreadItem, + ) -> Result { { let conn = lock_db(&self.db)?; AgentTimelineDao::upsert_item(&conn, &item) @@ -400,8 +390,7 @@ impl AgentTimelineRecorder { } _ => RuntimeAgentEvent::ItemUpdated { item: item.clone() }, }; - emit_event(app, event_name, &event); - Ok(()) + Ok(event) } fn persist_runtime_item( @@ -434,3 +423,71 @@ impl AgentTimelineRecorder { } } } + +#[cfg(test)] +mod tests { + use super::*; + use lime_core::database::dao::agent_timeline::{AgentThreadTurnStatus, AgentTimelineDao}; + use lime_core::database::schema::create_tables; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + + fn setup_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建 agent timeline 表失败"); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + "thread-1", + "general:test", + "2026-03-13T00:00:00Z", + "2026-03-13T00:00:00Z" + ], + ) + .expect("创建测试 session"); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn fail_turn_should_persist_failed_turn_before_emitting_events() { + let db = setup_db(); + let mut recorder = AgentTimelineRecorder::create(db.clone(), "thread-1", "turn-1", "hello") + .expect("创建 recorder"); + + let events = recorder.fail_turn("boom").expect("写入失败终态"); + + let conn = lock_db(&db).expect("获取数据库锁"); + let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, AgentThreadTurnStatus::Failed); + assert_eq!(turns[0].error_message.as_deref(), Some("boom")); + drop(conn); + + assert!(events + .iter() + .any(|event| matches!(event, RuntimeAgentEvent::TurnFailed { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, RuntimeAgentEvent::ItemCompleted { item } if item.id == "error:turn-1"))); + } + + #[test] + fn complete_turn_success_should_persist_completed_turn_before_emitting_events() { + let db = setup_db(); + let mut recorder = AgentTimelineRecorder::create(db.clone(), "thread-1", "turn-1", "hello") + .expect("创建 recorder"); + + let events = recorder.complete_turn_success().expect("写入完成终态"); + + let conn = lock_db(&db).expect("获取数据库锁"); + let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, AgentThreadTurnStatus::Completed); + assert!(turns[0].completed_at.is_some()); + drop(conn); + + assert!(events + .iter() + .any(|event| matches!(event, RuntimeAgentEvent::TurnCompleted { .. }))); + } +} diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index dab7042c8..4983beed6 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -1119,6 +1119,7 @@ mod tests { }], interrupt_state: None, updated_at: Some("2026-03-27T10:01:20Z".to_string()), + latest_compaction_boundary: None, diagnostics: Some(AgentRuntimeThreadDiagnostics { latest_turn_status: Some("action_required".to_string()), latest_turn_started_at: None, diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index 36922e9f1..2f0ec8dea 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -885,6 +885,7 @@ mod tests { }], interrupt_state: None, updated_at: Some("2026-03-27T10:01:00Z".to_string()), + latest_compaction_boundary: None, diagnostics: Some( crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics { latest_turn_status: Some("running".to_string()), diff --git a/src-tauri/src/services/runtime_handoff_artifact_service.rs b/src-tauri/src/services/runtime_handoff_artifact_service.rs index 92032a6a5..4df31fbf1 100644 --- a/src-tauri/src/services/runtime_handoff_artifact_service.rs +++ b/src-tauri/src/services/runtime_handoff_artifact_service.rs @@ -966,6 +966,7 @@ mod tests { }], interrupt_state: None, updated_at: Some("2026-03-27T10:01:00Z".to_string()), + latest_compaction_boundary: None, diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics { latest_turn_status: Some("completed".to_string()), latest_turn_started_at: None, diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index b8f4fff74..dad7021c1 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -1164,6 +1164,7 @@ mod tests { }], interrupt_state: None, updated_at: Some("2026-03-27T10:02:00Z".to_string()), + latest_compaction_boundary: None, diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics { latest_turn_status: Some("completed".to_string()), latest_turn_started_at: None, diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs index d3e89737e..a7a7ddd2d 100644 --- a/src-tauri/src/services/runtime_review_decision_service.rs +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -753,6 +753,7 @@ mod tests { }], interrupt_state: None, updated_at: Some("2026-03-27T10:01:20Z".to_string()), + latest_compaction_boundary: None, diagnostics: Some(AgentRuntimeThreadDiagnostics { latest_turn_status: Some("action_required".to_string()), latest_turn_started_at: None, diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 1b6ff59c5..56d19bf4d 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.7.0", + "version": "1.8.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a426f8613..05bd6b850 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.7.0", + "version": "1.8.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index bd6311974..0618804a8 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -112,7 +112,7 @@ describe("AppSidebar", () => { ); }); - it("旧导航配置未包含能力入口时也应显示固定能力分组和核心能力项", async () => { + it("旧导航配置未包含能力入口时也应显示固定能力分组和核心入口", async () => { mockGetConfig.mockResolvedValue({ navigation: { enabled_items: ["home-general", "claw"], @@ -127,13 +127,13 @@ describe("AppSidebar", () => { await flushEffects(); expect(container.textContent).toContain("能力"); - expect(container.textContent).toContain("技能"); + expect(container.textContent).toContain("我的方法"); expect(container.textContent).toContain("消息渠道"); expect(container.textContent).toContain("资料"); expect(container.textContent).toContain("资料库"); expect(container.textContent).toContain("灵感库"); expect(container.textContent).not.toContain("视频"); - expect(container.textContent).not.toContain("自动化"); + expect(container.textContent).not.toContain("持续流程"); }); it("进入消息渠道页时应高亮对应能力入口并保留能力分组标题", async () => { @@ -150,6 +150,20 @@ describe("AppSidebar", () => { ).not.toBeNull(); }); + it("默认应隐藏系统区的可选入口,只保留设置", async () => { + const container = mountSidebar({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(); + + expect(container.textContent).toContain("系统"); + expect(container.textContent).toContain("设置"); + expect(container.textContent).not.toContain("终端"); + expect(container.textContent).not.toContain("桌宠"); + }); + it("侧边栏不应再渲染旧主题分组标题", async () => { const container = mountSidebar({ currentPageParams: { diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 82261f1d7..05b8b0000 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -195,8 +195,8 @@ import { isAbsoluteWorkspacePath, resolveAbsoluteWorkspacePath, } from "./workspace/workspacePath"; +import { doesWorkspaceFileCandidateMatch } from "./workspace/workspaceFilePathMatch"; import { - areArtifactProtocolPathsEquivalent, normalizeArtifactProtocolPath, resolveArtifactProtocolFilePath, } from "@/lib/artifact-protocol"; @@ -293,13 +293,10 @@ function resolveTaskPreviewArtifact( !isHiddenInternalArtifactPath( resolveArtifactProtocolFilePath(artifact), ) && - (areArtifactProtocolPathsEquivalent( + doesWorkspaceFileCandidateMatch( resolveArtifactProtocolFilePath(artifact), normalizedArtifactPath, - ) || - normalizeArtifactProtocolPath( - resolveArtifactProtocolFilePath(artifact), - ) === normalizedArtifactPath), + ), ); if (matchedArtifact) { return matchedArtifact; @@ -3207,12 +3204,7 @@ export function AgentChatWorkspace({ ); if (normalizedArtifactPath) { const matchedTaskFile = taskFiles.find( - (file) => - areArtifactProtocolPathsEquivalent( - file.name, - normalizedArtifactPath, - ) || - normalizeArtifactProtocolPath(file.name) === normalizedArtifactPath, + (file) => doesWorkspaceFileCandidateMatch(file.name, normalizedArtifactPath), ); if (matchedTaskFile?.content?.trim()) { handleWorkspaceFileClick( @@ -3292,10 +3284,7 @@ export function AgentChatWorkspace({ } const matchedTaskFile = taskFiles.find( - (file) => - areArtifactProtocolPathsEquivalent(file.name, normalizedPath) || - normalizeArtifactProtocolPath(file.name) === - normalizeArtifactProtocolPath(normalizedPath), + (file) => doesWorkspaceFileCandidateMatch(file.name, normalizedPath), ); if (matchedTaskFile) { handleWorkspaceFileClick( @@ -3305,6 +3294,10 @@ export function AgentChatWorkspace({ return; } + if (absolutePath) { + return; + } + toast.error("打开结果文件失败:当前工作区里还没有同步到这份文件"); }, [ @@ -3615,6 +3608,7 @@ export function AgentChatWorkspace({ setModel, sessionExecutionRuntime: executionRuntime, projectId: projectId ?? null, + projectRootPath: project?.rootPath || null, executionStrategy, setExecutionStrategy, accessMode, diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index 5ef45fc4e..fa977d83e 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -141,8 +141,8 @@ export const AgentRuntimeStrip: React.FC = ({ key: "team_running", label: queuedTeamSessions > 0 - ? `协作处理中 ${activeTeamSessions}/${childSubagentSessions.length} · 稍后开始 ${queuedTeamSessions}` - : `协作处理中 ${activeTeamSessions}/${childSubagentSessions.length}`, + ? `任务进行中 ${activeTeamSessions}/${childSubagentSessions.length} · 稍后开始 ${queuedTeamSessions}` + : `任务进行中 ${activeTeamSessions}/${childSubagentSessions.length}`, tone: "secondary", }); } else if (childSubagentSessions.length > 0) { diff --git a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx new file mode 100644 index 000000000..83c04cf43 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx @@ -0,0 +1,329 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@/components/ui/badge"; +import type { TurnMemoryPrefetchResult } from "@/lib/api/memoryRuntime"; +import { cn } from "@/lib/utils"; + +type RuntimeMemoryPrefetchStatus = "idle" | "loading" | "ready" | "error"; + +interface AgentThreadMemoryPrefetchPreviewProps { + status: RuntimeMemoryPrefetchStatus; + result: TurnMemoryPrefetchResult | null; + error: string | null; + actions?: ReactNode; + className?: string; +} + +const DURABLE_CATEGORY_LABELS: Record = { + identity: "风格", + context: "参考", + preference: "偏好", + experience: "成果", + activity: "收藏", +}; + +function normalizeText(value?: string | null): string { + return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : ""; +} + +function truncateText(value?: string | null, maxLength = 240): string { + const normalized = normalizeText(value); + if (!normalized) { + return ""; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function parseDate(value?: string | number | null): Date | null { + if (typeof value === "number") { + const normalizedValue = value > 10_000_000_000 ? value : value * 1000; + const date = new Date(normalizedValue); + return Number.isNaN(date.getTime()) ? null : date; + } + + if (typeof value === "string" && value.trim()) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + return null; +} + +function formatDateTime(value?: string | number | null): string | null { + const date = parseDate(value); + if (!date) { + return null; + } + + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatMemoryLayerStatusLabel( + label: string, + count?: number | null, + active?: boolean, +): string { + if (typeof count === "number") { + return `${label} ${count}`; + } + return `${label} ${active ? "已命中" : "未命中"}`; +} + +function formatDurableCategoryLabel(category: string): string { + return DURABLE_CATEGORY_LABELS[category] || category; +} + +function DetailPanel(props: { + title: string; + emptyText: string; + children?: ReactNode; +}) { + return ( +
+
{props.title}
+
+ {props.children || ( +
{props.emptyText}
+ )} +
+
+ ); +} + +export function AgentThreadMemoryPrefetchPreview({ + status, + result, + error, + actions, + className, +}: AgentThreadMemoryPrefetchPreviewProps) { + return ( +
+
+
+
+ 本回合记忆预取 +
+
+ + {status === "loading" + ? "加载中" + : status === "ready" + ? "五层记忆预演" + : status === "error" + ? "暂不可用" + : "待预取"} + +
+
+ {actions ?
{actions}
: null} +
+
+ {status === "loading" + ? "正在按最新回合 prompt 预演 rules / working / durable / team / compaction 五层记忆命中。" + : status === "error" + ? error + : "下面展示的是当前这轮真实会用到的记忆命中预演,不会改写会话,只帮助判断续接质量。"} +
+ + {result ? ( + <> +
+ + {formatMemoryLayerStatusLabel("规则", result.rules_source_paths.length)} + + + {formatMemoryLayerStatusLabel( + "工作", + null, + Boolean(result.working_memory_excerpt), + )} + + + {formatMemoryLayerStatusLabel("持久", result.durable_memories.length)} + + + {formatMemoryLayerStatusLabel("Team", result.team_memory_entries.length)} + + + {formatMemoryLayerStatusLabel( + "压缩", + null, + Boolean(result.latest_compaction), + )} + +
+ +
+ + {result.rules_source_paths.length > 0 ? ( +
+ {result.rules_source_paths.slice(0, 3).map((path) => ( +
+ {path} +
+ ))} + {result.rules_source_paths.length > 3 ? ( +
+ 另有 {result.rules_source_paths.length - 3} 个来源未展开。 +
+ ) : null} +
+ ) : null} +
+ + + {result.working_memory_excerpt ? ( +
+ {truncateText(result.working_memory_excerpt, 320)} +
+ ) : null} +
+ + + {result.durable_memories.length > 0 ? ( +
+ {result.durable_memories.slice(0, 3).map((entry) => ( +
+
+ + {formatDurableCategoryLabel(entry.category)} + + + {entry.title} + +
+
+ {truncateText(entry.summary, 220)} +
+
+ {entry.tags.length > 0 ? ( + 标签:{entry.tags.join("、")} + ) : null} + {formatDateTime(entry.updated_at) ? ( + 更新于 {formatDateTime(entry.updated_at)} + ) : null} +
+
+ ))} + {result.durable_memories.length > 3 ? ( +
+ 另有 {result.durable_memories.length - 3} 条长期记忆未展开。 +
+ ) : null} +
+ ) : null} +
+ + + {result.team_memory_entries.length > 0 ? ( +
+ {result.team_memory_entries.slice(0, 3).map((entry) => ( +
+
+ + {entry.key} + + {formatDateTime(entry.updated_at) ? ( + + {formatDateTime(entry.updated_at)} + + ) : null} +
+
+ {truncateText(entry.content, 220)} +
+
+ ))} + {result.team_memory_entries.length > 3 ? ( +
+ 另有 {result.team_memory_entries.length - 3} 条 Team shadow 未展开。 +
+ ) : null} +
+ ) : null} +
+ + + {result.latest_compaction ? ( +
+
+ {result.latest_compaction.trigger ? ( + 触发原因:{result.latest_compaction.trigger} + ) : null} + {typeof result.latest_compaction.turn_count === "number" ? ( + 覆盖回合:{result.latest_compaction.turn_count} + ) : null} + {formatDateTime(result.latest_compaction.created_at) ? ( + + 生成于 {formatDateTime(result.latest_compaction.created_at)} + + ) : null} +
+
+ {truncateText(result.latest_compaction.summary_preview, 260)} +
+
+ ) : null} +
+ + + {result.prompt ? ( +
+                  {truncateText(result.prompt, 500)}
+                
+ ) : null} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 8d65b92f4..a27d33e42 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -11,19 +11,26 @@ import type { Message, } from "../types"; import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; +import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import type { HarnessSessionState } from "../utils/harnessState"; +import { recordRuntimeMemoryPrefetchHistory } from "@/lib/runtimeMemoryPrefetchHistory"; -const { mockToast } = vi.hoisted(() => ({ +const { mockToast, mockPrefetchContextMemoryForTurn } = vi.hoisted(() => ({ mockToast: { success: vi.fn(), error: vi.fn(), }, + mockPrefetchContextMemoryForTurn: vi.fn(), })); vi.mock("sonner", () => ({ toast: mockToast, })); +vi.mock("@/lib/api/memoryRuntime", () => ({ + prefetchContextMemoryForTurn: mockPrefetchContextMemoryForTurn, +})); + interface MountedHarness { container: HTMLDivElement; root: Root; @@ -46,6 +53,16 @@ beforeEach(() => { writeText: vi.fn().mockResolvedValue(undefined), }, }); + window.localStorage.clear(); + mockPrefetchContextMemoryForTurn.mockResolvedValue({ + session_id: "session-default", + rules_source_paths: [], + working_memory_excerpt: null, + durable_memories: [], + team_memory_entries: [], + latest_compaction: null, + prompt: null, + }); }); afterEach(() => { @@ -77,11 +94,14 @@ function renderPanel(props?: { onReplayPendingRequest?: (requestId: string) => boolean | Promise; onLocatePendingRequest?: (requestId: string) => void; onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise; + onOpenMemoryWorkbench?: () => void; harnessState?: HarnessSessionState | null; messages?: Message[]; + teamMemorySnapshot?: TeamMemorySnapshot | null; diagnosticRuntimeContext?: { sessionId?: string | null; workspaceId?: string | null; + workingDir?: string | null; providerType?: string | null; model?: string | null; executionStrategy?: string | null; @@ -108,8 +128,10 @@ function renderPanel(props?: { onReplayPendingRequest={props?.onReplayPendingRequest} onLocatePendingRequest={props?.onLocatePendingRequest} onPromoteQueuedTurn={props?.onPromoteQueuedTurn} + onOpenMemoryWorkbench={props?.onOpenMemoryWorkbench} harnessState={props?.harnessState} messages={props?.messages} + teamMemorySnapshot={props?.teamMemorySnapshot} diagnosticRuntimeContext={props?.diagnosticRuntimeContext} />, ); @@ -502,12 +524,306 @@ describe("AgentThreadReliabilityPanel", () => { expect(onReplayPendingRequest).toHaveBeenCalledWith("req-replay-1"); }); + it("应展示本回合五层记忆预取,帮助判断五层续接是否命中", async () => { + mockPrefetchContextMemoryForTurn.mockResolvedValue({ + session_id: "session-memory-1", + rules_source_paths: ["/workspace/AGENTS.md", "/workspace/.memory/rules.md"], + working_memory_excerpt: "【task_plan.md】继续整理风险点与来源摘要。", + durable_memories: [ + { + id: "durable-1", + session_id: "session-memory-1", + category: "experience", + title: "研究简报输出偏好", + summary: "优先给风险与证据链", + updated_at: 1711184700, + tags: ["research"], + }, + ], + team_memory_entries: [ + { + key: "team.selection", + content: "研究协作队", + updated_at: 1711184700, + }, + ], + latest_compaction: { + session_id: "session-memory-1", + source: "summary_cache", + summary_preview: "保留研究目标与来源摘要。", + created_at: 1711184700, + trigger: "token_budget", + }, + prompt: "【运行时记忆召回】...", + }); + + const container = renderPanel({ + turns: [ + { + id: "turn-memory-1", + thread_id: "thread-memory-1", + prompt_text: "继续输出研究简报的风险点", + status: "running", + started_at: "2026-03-23T10:00:00Z", + created_at: "2026-03-23T10:00:00Z", + updated_at: "2026-03-23T10:01:00Z", + }, + ], + diagnosticRuntimeContext: { + sessionId: "session-memory-1", + workspaceId: "workspace-memory-1", + workingDir: "/workspace/project-a", + }, + teamMemorySnapshot: { + repoScope: "/workspace/project-a", + entries: { + "team.selection": { + key: "team.selection", + content: "研究协作队", + updatedAt: 1711184700, + }, + }, + }, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockPrefetchContextMemoryForTurn).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: "session-memory-1", + working_dir: "/workspace/project-a", + user_message: "继续输出研究简报的风险点", + request_metadata: expect.objectContaining({ + team_memory_shadow: expect.objectContaining({ + repo_scope: "/workspace/project-a", + }), + }), + }), + ); + expect( + container.querySelector( + '[data-testid="agent-thread-reliability-memory-prefetch"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("本回合记忆预取"); + expect(container.textContent).toContain("五层记忆预演"); + expect(container.textContent).toContain("规则 2"); + expect(container.textContent).toContain("工作 已命中"); + expect(container.textContent).toContain("持久 1"); + expect(container.textContent).toContain("Team 1"); + expect(container.textContent).toContain("压缩 已命中"); + expect(container.textContent).toContain("工作记忆摘录"); + expect(container.textContent).toContain("继续整理风险点与来源摘要"); + expect(container.textContent).toContain("规则来源"); + expect(container.textContent).toContain("/workspace/AGENTS.md"); + expect(container.textContent).toContain("持久记忆命中"); + expect(container.textContent).toContain("研究简报输出偏好"); + expect(container.textContent).toContain("优先给风险与证据链"); + expect(container.textContent).toContain("Team Shadow 明细"); + expect(container.textContent).toContain("team.selection"); + expect(container.textContent).toContain("研究协作队"); + expect(container.textContent).toContain("压缩续接摘要"); + expect(container.textContent).toContain("保留研究目标与来源摘要"); + expect(container.textContent).toContain("运行时记忆片段"); + expect(container.textContent).toContain("【运行时记忆召回】"); + }); + + it("应展示相对最近基线的记忆变化摘要", async () => { + recordRuntimeMemoryPrefetchHistory({ + sessionId: "session-memory-compare-1", + workingDir: "/workspace/project-a", + userMessage: "继续输出旧版简报", + source: "memory_page", + capturedAt: 1_711_184_600_000, + result: { + session_id: "session-memory-compare-1", + rules_source_paths: ["/workspace/AGENTS.md"], + working_memory_excerpt: null, + durable_memories: [], + team_memory_entries: [], + latest_compaction: null, + prompt: null, + }, + }); + + mockPrefetchContextMemoryForTurn.mockResolvedValue({ + session_id: "session-memory-compare-1", + rules_source_paths: ["/workspace/AGENTS.md", "/workspace/.memory/rules.md"], + working_memory_excerpt: "【task_plan.md】补上新版风险结论。", + durable_memories: [ + { + id: "durable-compare-1", + session_id: "session-memory-compare-1", + category: "experience", + title: "研究简报结构偏好", + summary: "先结论,再列风险", + updated_at: 1711184700, + tags: ["research"], + }, + ], + team_memory_entries: [], + latest_compaction: null, + prompt: null, + }); + + const container = renderPanel({ + turns: [ + { + id: "turn-memory-compare-1", + thread_id: "thread-memory-compare-1", + prompt_text: "继续输出新版研究简报", + status: "running", + started_at: "2026-03-23T10:00:00Z", + created_at: "2026-03-23T10:00:00Z", + updated_at: "2026-03-23T10:01:00Z", + }, + ], + diagnosticRuntimeContext: { + sessionId: "session-memory-compare-1", + workspaceId: "workspace-memory-compare-1", + workingDir: "/workspace/project-a", + }, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("相对最近基线"); + expect(container.textContent).toContain("基线输入:继续输出旧版简报"); + expect(container.textContent).toContain("补强"); + expect(container.textContent).toContain("补强层:规则层、工作层、持久层。 摘要内容也有更新。"); + expect(container.textContent).toContain("具体变化"); + expect(container.textContent).toContain("规则 +1"); + expect(container.textContent).toContain("工作 新命中"); + expect(container.textContent).toContain("持久 +1"); + expect(container.textContent).toContain( + "工作摘录 无 -> 【task_plan.md】补上新版风险结论。", + ); + }); + + it("应支持从线程可靠性面板跳到记忆工作台查看当前预演", async () => { + const onOpenMemoryWorkbench = vi.fn(); + const container = renderPanel({ + turns: [ + { + id: "turn-memory-open-1", + thread_id: "thread-memory-open-1", + prompt_text: "继续整理运行时记忆", + status: "running", + started_at: "2026-03-23T10:00:00Z", + created_at: "2026-03-23T10:00:00Z", + updated_at: "2026-03-23T10:01:00Z", + }, + ], + diagnosticRuntimeContext: { + sessionId: "session-memory-open-1", + workspaceId: "workspace-memory-open-1", + workingDir: "/workspace/project-b", + }, + onOpenMemoryWorkbench, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const button = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("在记忆工作台查看"), + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenMemoryWorkbench).toHaveBeenCalledTimes(1); + }); + + it("应展示最近压缩边界,帮助判断压缩后的工作记忆续接", () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-compaction-1", + status: "completed", + pending_requests: [], + incidents: [], + latest_compaction_boundary: { + session_id: "session-compaction-1", + summary_preview: + "保留研究目标、已确认来源和待输出风险点,后续回答应沿这条摘要继续。", + turn_count: 8, + created_at: "2026-03-23T10:05:00Z", + trigger: "token_budget", + detail: "压缩后保留研究目标与来源摘要", + }, + }, + }); + + expect( + container.querySelector( + '[data-testid="agent-thread-reliability-compaction-boundary"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("最近压缩边界"); + expect(container.textContent).toContain("token_budget"); + expect(container.textContent).toContain("覆盖 8 回合"); + expect(container.textContent).toContain("保留研究目标、已确认来源和待输出风险点"); + expect(container.textContent).toContain("压缩备注 压缩后保留研究目标与来源摘要"); + }); + it("应支持复制给 AI 的可靠性诊断包", async () => { + mockPrefetchContextMemoryForTurn.mockResolvedValue({ + session_id: "session-diag-1", + rules_source_paths: ["/workspace/AGENTS.md"], + working_memory_excerpt: "【progress.md】继续生成研究简报。", + durable_memories: [ + { + id: "durable-diag-1", + session_id: "session-diag-1", + category: "experience", + title: "研究输出格式偏好", + summary: "先给结论,再列风险", + updated_at: 1711184700, + tags: ["report"], + }, + ], + team_memory_entries: [ + { + key: "team.selection", + content: "研究协作队", + updated_at: 1711184700, + }, + ], + latest_compaction: { + session_id: "session-diag-1", + source: "summary_cache", + summary_preview: "保留研究目标与最近来源摘要。", + created_at: 1711184700, + trigger: "token_budget", + }, + prompt: "【运行时记忆召回】...", + }); + const container = renderPanel({ threadRead: { thread_id: "thread-1", status: "aborted", active_turn_id: "turn-1", + latest_compaction_boundary: { + session_id: "session-diag-1", + summary_preview: + "保留研究目标、最近来源摘要和待输出风险点,后续回答应基于这段摘要继续。", + turn_count: 6, + created_at: "2026-03-23T10:01:05Z", + trigger: "token_budget", + detail: "压缩后保留研究目标与最近来源摘要", + }, diagnostics: { latest_turn_status: "aborted", latest_turn_started_at: "2026-03-23T10:00:00Z", @@ -657,12 +973,28 @@ describe("AgentThreadReliabilityPanel", () => { diagnosticRuntimeContext: { sessionId: "session-diag-1", workspaceId: "workspace-diag-1", + workingDir: "/workspace/research", providerType: "openai", model: "gpt-5.4", executionStrategy: "react", activeTheme: "general", selectedTeamLabel: "研究协作队", }, + teamMemorySnapshot: { + repoScope: "/workspace/research", + entries: { + "team.selection": { + key: "team.selection", + content: "研究协作队", + updatedAt: 1711184700, + }, + }, + }, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); }); const copyButton = container.querySelector( @@ -690,6 +1022,39 @@ describe("AgentThreadReliabilityPanel", () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("### Harness 过程信号"), ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### 最近压缩边界"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### 当前回合记忆预取"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("规则层:1 个来源"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("Team 层:1 条"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("规则来源:/workspace/AGENTS.md"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("持久记忆详情:研究输出格式偏好|先给结论,再列风险"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("Team 影子详情:team.selection|研究协作队"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("压缩命中摘要:保留研究目标与最近来源摘要。"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("运行时记忆片段:【运行时记忆召回】"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("覆盖回合数:6"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("边界摘要:保留研究目标、最近来源摘要和待输出风险点"), + ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("### 后端诊断聚合"), ); @@ -722,11 +1087,29 @@ describe("AgentThreadReliabilityPanel", () => { }); it("应支持复制原始 JSON 诊断数据", async () => { + mockPrefetchContextMemoryForTurn.mockResolvedValue({ + session_id: "session-json-1", + rules_source_paths: ["/workspace/AGENTS.md"], + working_memory_excerpt: null, + durable_memories: [], + team_memory_entries: [], + latest_compaction: null, + prompt: null, + }); + const container = renderPanel({ threadRead: { thread_id: "thread-json-1", status: "waiting_request", active_turn_id: "turn-json-1", + latest_compaction_boundary: { + session_id: "session-json-1", + summary_preview: "保留用户确认点与待继续执行动作。", + turn_count: 3, + created_at: "2026-03-23T10:00:20Z", + trigger: "manual_compact", + detail: "压缩后等待用户输入", + }, diagnostics: { latest_turn_status: "running", warning_count: 0, @@ -802,6 +1185,7 @@ describe("AgentThreadReliabilityPanel", () => { diagnosticRuntimeContext: { sessionId: "session-json-1", workspaceId: "workspace-json-1", + workingDir: "/workspace/json", providerType: "openai", model: "gpt-5.4-mini", executionStrategy: "react", @@ -810,6 +1194,11 @@ describe("AgentThreadReliabilityPanel", () => { }, }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const jsonButton = container.querySelector( '[data-testid="agent-thread-reliability-copy-json"]', ); @@ -826,6 +1215,12 @@ describe("AgentThreadReliabilityPanel", () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining('"backend_diagnostics"'), ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"latest_compaction_boundary"'), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"memory_prefetch_preview"'), + ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining('"harness_state"'), ); diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index cc6085c81..3d2e400a3 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { AlertTriangle, Clock3, @@ -13,6 +13,24 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + prefetchContextMemoryForTurn, + type TurnMemoryPrefetchResult, +} from "@/lib/api/memoryRuntime"; +import { + buildTeamMemoryShadowRequestMetadata, + type TeamMemorySnapshot, +} from "@/lib/teamMemorySync"; +import { + assessRuntimeMemoryPrefetchHistoryDiff, + compareRuntimeMemoryPrefetchHistoryEntries, + describeRuntimeMemoryPrefetchHistoryDiffAssessment, + formatRuntimeMemoryPrefetchHistoryDiffStatusLabel, + recordRuntimeMemoryPrefetchHistory, + type RuntimeMemoryPrefetchHistoryDiff, + type RuntimeMemoryPrefetchHistoryDiffAssessment, + type RuntimeMemoryPrefetchHistoryEntry, +} from "@/lib/runtimeMemoryPrefetchHistory"; import { cn } from "@/lib/utils"; import type { AgentRuntimeThreadReadModel, @@ -30,11 +48,13 @@ import { type ThreadReliabilityTone, } from "../utils/threadReliabilityView"; import { AgentIncidentPanel } from "./AgentIncidentPanel"; +import { AgentThreadMemoryPrefetchPreview } from "./AgentThreadMemoryPrefetchPreview"; import { AgentThreadOutcomeSummary } from "./AgentThreadOutcomeSummary"; interface AgentThreadReliabilityDiagnosticContext { sessionId?: string | null; workspaceId?: string | null; + workingDir?: string | null; providerType?: string | null; model?: string | null; executionStrategy?: string | null; @@ -56,12 +76,26 @@ interface AgentThreadReliabilityPanelProps { onReplayPendingRequest?: (requestId: string) => boolean | Promise; onLocatePendingRequest?: (requestId: string) => void; onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise; + onOpenMemoryWorkbench?: () => void; className?: string; harnessState?: HarnessSessionState | null; messages?: Message[]; + teamMemorySnapshot?: TeamMemorySnapshot | null; diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null; } +interface RuntimeMemoryPrefetchState { + status: "idle" | "loading" | "ready" | "error"; + result: TurnMemoryPrefetchResult | null; + error: string | null; +} + +interface RuntimeMemoryPrefetchComparisonState { + baselineEntry: RuntimeMemoryPrefetchHistoryEntry | null; + diff: RuntimeMemoryPrefetchHistoryDiff | null; + assessment: RuntimeMemoryPrefetchHistoryDiffAssessment | null; +} + function serializeClipboardPayload(value: unknown): string { return JSON.stringify( value, @@ -88,6 +122,35 @@ function truncateDiagnosticText( return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; } +function parseDiagnosticDate(value?: string | number | null): Date | null { + if (typeof value === "number") { + const normalizedValue = value > 10_000_000_000 ? value : value * 1000; + const date = new Date(normalizedValue); + return Number.isNaN(date.getTime()) ? null : date; + } + + if (typeof value === "string" && value.trim()) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + return null; +} + +function formatDiagnosticDateTime(value?: string | number | null): string | null { + const date = parseDiagnosticDate(value); + if (!date) { + return null; + } + + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + function summarizeThreadItemSignals(threadItems: AgentThreadItem[]) { const warningItems = threadItems.filter((item) => item.type === "warning"); const contextCompactionItems = threadItems.filter( @@ -285,6 +348,173 @@ function resolveStatShellClassName(tone: ThreadReliabilityTone) { } } +function buildMemoryPrefetchDiagnosticLines( + memoryPrefetchState?: RuntimeMemoryPrefetchState, +): string[] { + if (memoryPrefetchState?.result) { + const prefetch = memoryPrefetchState.result; + const sections = [ + `- 规则层:${prefetch.rules_source_paths.length} 个来源`, + `- 工作层:${prefetch.working_memory_excerpt ? "已命中" : "未命中"}`, + `- 持久层:${prefetch.durable_memories.length} 条`, + `- Team 层:${prefetch.team_memory_entries.length} 条`, + `- 压缩层:${prefetch.latest_compaction ? "已命中" : "未命中"}`, + ]; + + if (prefetch.rules_source_paths.length > 0) { + sections.push( + `- 规则来源:${prefetch.rules_source_paths + .slice(0, 3) + .map((path) => truncateDiagnosticText(path, 120)) + .join("|")}`, + ); + } + if (prefetch.working_memory_excerpt) { + sections.push( + `- 工作记忆摘录:${truncateDiagnosticText(prefetch.working_memory_excerpt, 220)}`, + ); + } + if (prefetch.durable_memories.length > 0) { + sections.push( + `- 持久记忆命中:${prefetch.durable_memories + .slice(0, 3) + .map((entry) => entry.title) + .join("|")}`, + ); + sections.push( + `- 持久记忆详情:${prefetch.durable_memories + .slice(0, 3) + .map( + (entry) => + `${truncateDiagnosticText(entry.title, 80)}|${truncateDiagnosticText(entry.summary, 120)}`, + ) + .join(" || ")}`, + ); + } + if (prefetch.team_memory_entries.length > 0) { + sections.push( + `- Team 影子键:${prefetch.team_memory_entries + .slice(0, 3) + .map((entry) => entry.key) + .join("|")}`, + ); + sections.push( + `- Team 影子详情:${prefetch.team_memory_entries + .slice(0, 3) + .map( + (entry) => + `${truncateDiagnosticText(entry.key, 80)}|${truncateDiagnosticText(entry.content, 120)}`, + ) + .join(" || ")}`, + ); + } + if (prefetch.latest_compaction) { + sections.push( + `- 压缩命中摘要:${truncateDiagnosticText(prefetch.latest_compaction.summary_preview, 180)}`, + ); + sections.push( + `- 压缩命中元数据:触发=${prefetch.latest_compaction.trigger || "未知"}|覆盖回合=${prefetch.latest_compaction.turn_count ?? "未知"}`, + ); + } + if (prefetch.prompt) { + sections.push( + `- 运行时记忆片段:${truncateDiagnosticText(prefetch.prompt, 220)}`, + ); + } + + return sections; + } + + if (memoryPrefetchState?.status === "loading") { + return ["- 正在按当前回合最新 prompt 预演五层记忆命中"]; + } + + if (memoryPrefetchState?.error) { + return [`- 失败:${memoryPrefetchState.error}`]; + } + + return ["- 无"]; +} + +function resolveMemoryPrefetchHistorySourceLabel( + source: RuntimeMemoryPrefetchHistoryEntry["source"], +): string { + return source === "thread_reliability" ? "线程面板" : "记忆工作台"; +} + +function resolveMemoryPrefetchPreviewChangeLabel( + change: RuntimeMemoryPrefetchHistoryDiff["previewChanges"][number], +): string { + switch (change.key) { + case "rule": + return `规则来源 ${change.previous || "无"} -> ${change.current || "无"}`; + case "working": + return `工作摘录 ${change.previous || "无"} -> ${change.current || "无"}`; + case "durable": + return `长期记忆 ${change.previous || "无"} -> ${change.current || "无"}`; + case "team": + return `Team 影子 ${change.previous || "无"} -> ${change.current || "无"}`; + case "compaction": + return `压缩摘要 ${change.previous || "无"} -> ${change.current || "无"}`; + case "user_message": + return `输入 ${change.previous || "无"} -> ${change.current || "无"}`; + default: + return `${change.previous || "无"} -> ${change.current || "无"}`; + } +} + +function resolveMemoryPrefetchAssessmentBadgeClassName( + assessment: RuntimeMemoryPrefetchHistoryDiffAssessment, +): string { + switch (assessment.status) { + case "stronger": + return "border-emerald-200 bg-white text-emerald-700"; + case "weaker": + return "border-amber-200 bg-white text-amber-700"; + case "mixed": + return "border-sky-200 bg-white text-sky-700"; + case "same": + default: + return "border-slate-200 bg-white text-slate-700"; + } +} + +function resolveMemoryPrefetchComparison( + entries: RuntimeMemoryPrefetchHistoryEntry[], + currentWorkingDir: string, +): RuntimeMemoryPrefetchComparisonState { + const currentEntry = entries[0]; + if (!currentEntry) { + return { + baselineEntry: null, + diff: null, + assessment: null, + }; + } + + const normalizedCurrentWorkingDir = + currentWorkingDir.trim().replace(/\\/g, "/").replace(/\/+$/u, ""); + const candidates = entries.slice(1); + const baselineEntry = + candidates.find((entry) => entry.sessionId === currentEntry.sessionId) || + candidates.find( + (entry) => + entry.workingDir.replace(/\\/g, "/").replace(/\/+$/u, "") === + normalizedCurrentWorkingDir, + ) || + candidates[0] || + null; + const diff = baselineEntry + ? compareRuntimeMemoryPrefetchHistoryEntries(currentEntry, baselineEntry) + : null; + + return { + baselineEntry, + diff, + assessment: diff ? assessRuntimeMemoryPrefetchHistoryDiff(diff) : null, + }; +} + function buildReliabilityDiagnosticText(params: { threadRead?: AgentRuntimeThreadReadModel | null; statusLabel: string; @@ -293,6 +523,8 @@ function buildReliabilityDiagnosticText(params: { threadItems: AgentThreadItem[]; messages: Message[]; harnessState?: HarnessSessionState | null; + memoryPrefetchState?: RuntimeMemoryPrefetchState; + memoryPrefetchComparison?: RuntimeMemoryPrefetchComparisonState; diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null; }): string { const { @@ -303,6 +535,8 @@ function buildReliabilityDiagnosticText(params: { threadItems, messages, harnessState, + memoryPrefetchState, + memoryPrefetchComparison, diagnosticRuntimeContext, } = params; const threadItemSignals = summarizeThreadItemSignals(threadItems); @@ -341,6 +575,7 @@ function buildReliabilityDiagnosticText(params: { `- 执行策略:${diagnosticRuntimeContext?.executionStrategy || "未知"}`, `- 主题:${diagnosticRuntimeContext?.activeTheme || "未知"}`, `- 协作方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`, + `- 工作区根目录:${diagnosticRuntimeContext?.workingDir || "未知"}`, "", "### 当前状态", `- 状态:${statusLabel}`, @@ -455,6 +690,68 @@ function buildReliabilityDiagnosticText(params: { sections.push("- 无"); } + sections.push("", "### 最近压缩边界"); + if (threadRead?.latest_compaction_boundary) { + const boundary = threadRead.latest_compaction_boundary; + sections.push(`- 生成时间:${boundary.created_at || "未知"}`); + sections.push(`- 覆盖回合数:${boundary.turn_count ?? "未知"}`); + sections.push(`- 触发原因:${boundary.trigger || "未知"}`); + sections.push( + `- 边界摘要:${boundary.summary_preview || "无摘要预览"}`, + ); + sections.push(`- 压缩备注:${boundary.detail || "无"}`); + } else { + sections.push("- 无"); + } + + sections.push("", "### 当前回合记忆预取"); + sections.push(...buildMemoryPrefetchDiagnosticLines(memoryPrefetchState)); + + sections.push("", "### 相对最近基线的记忆变化"); + if (memoryPrefetchComparison?.baselineEntry && memoryPrefetchComparison.diff) { + sections.push( + `- 基线来源:${resolveMemoryPrefetchHistorySourceLabel(memoryPrefetchComparison.baselineEntry.source)}`, + ); + sections.push( + `- 基线输入:${memoryPrefetchComparison.baselineEntry.userMessage || "无"}`, + ); + sections.push( + `- 基线摘要:${truncateDiagnosticText( + memoryPrefetchComparison.baselineEntry.preview.durableTitle || + memoryPrefetchComparison.baselineEntry.preview.workingExcerpt || + memoryPrefetchComparison.baselineEntry.preview.compactionSummary || + memoryPrefetchComparison.baselineEntry.preview.firstRuleSourcePath || + memoryPrefetchComparison.baselineEntry.preview.teamKey, + 180, + ) || "无"}`, + ); + if (memoryPrefetchComparison.assessment) { + sections.push( + `- 对照判断:${formatRuntimeMemoryPrefetchHistoryDiffStatusLabel(memoryPrefetchComparison.assessment.status)}`, + ); + sections.push( + `- 对照结论:${describeRuntimeMemoryPrefetchHistoryDiffAssessment(memoryPrefetchComparison.assessment)}`, + ); + } + if (memoryPrefetchComparison.diff.changed) { + sections.push( + `- 层变化:规则 ${memoryPrefetchComparison.diff.layerChanges.rulesDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.rulesDelta}|工作 ${memoryPrefetchComparison.diff.layerChanges.workingChanged}|持久 ${memoryPrefetchComparison.diff.layerChanges.durableDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.durableDelta}|Team ${memoryPrefetchComparison.diff.layerChanges.teamDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.teamDelta}|压缩 ${memoryPrefetchComparison.diff.layerChanges.compactionChanged}`, + ); + if (memoryPrefetchComparison.diff.previewChanges.length > 0) { + sections.push( + `- 摘要变化:${memoryPrefetchComparison.diff.previewChanges + .slice(0, 4) + .map((change) => resolveMemoryPrefetchPreviewChangeLabel(change)) + .join(" || ")}`, + ); + } + } else { + sections.push("- 与最近基线相比没有明显变化"); + } + } else { + sections.push("- 暂无可对照的历史基线"); + } + sections.push("", "### 后端诊断聚合"); if (threadRead?.diagnostics) { const diagnostics = threadRead.diagnostics; @@ -571,12 +868,25 @@ function buildReliabilityRawPayload(params: { view: ReturnType; harnessState?: HarnessSessionState | null; messages: Message[]; + memoryPrefetchState?: RuntimeMemoryPrefetchState; + memoryPrefetchComparison?: RuntimeMemoryPrefetchComparisonState; diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null; }): Record { return { exported_at: new Date().toISOString(), runtime_context: params.diagnosticRuntimeContext || null, backend_diagnostics: params.threadRead?.diagnostics || null, + latest_compaction_boundary: + params.threadRead?.latest_compaction_boundary || null, + memory_prefetch_preview: params.memoryPrefetchState?.result || null, + memory_prefetch_error: params.memoryPrefetchState?.error || null, + memory_prefetch_comparison: params.memoryPrefetchComparison + ? { + baseline_entry: params.memoryPrefetchComparison.baselineEntry, + diff: params.memoryPrefetchComparison.diff, + assessment: params.memoryPrefetchComparison.assessment, + } + : null, current_turn_id: params.currentTurnId || null, thread_read: params.threadRead || null, turns: params.turns, @@ -607,15 +917,29 @@ export const AgentThreadReliabilityPanel: React.FC< onReplayPendingRequest, onLocatePendingRequest, onPromoteQueuedTurn, + onOpenMemoryWorkbench, className, harnessState = null, messages = [], + teamMemorySnapshot = null, diagnosticRuntimeContext = null, }) => { const [isInterrupting, setIsInterrupting] = useState(false); const [isResumingThread, setIsResumingThread] = useState(false); const [isReplayingRequest, setIsReplayingRequest] = useState(false); const [isPromotingQueuedTurn, setIsPromotingQueuedTurn] = useState(false); + const [memoryPrefetchState, setMemoryPrefetchState] = + useState({ + status: "idle", + result: null, + error: null, + }); + const [memoryPrefetchComparison, setMemoryPrefetchComparison] = + useState({ + baselineEntry: null, + diff: null, + assessment: null, + }); const view = useMemo( () => buildThreadReliabilityView({ @@ -642,6 +966,130 @@ export const AgentThreadReliabilityPanel: React.FC< const summary = isInterrupting ? "正在请求停止当前执行,等待运行时确认最新线程状态。" : view.summary; + const latestCompactionBoundary = threadRead?.latest_compaction_boundary || null; + const latestCompactionCreatedLabel = formatDiagnosticDateTime( + latestCompactionBoundary?.created_at, + ); + const latestCompactionDetail = truncateDiagnosticText( + latestCompactionBoundary?.detail, + 160, + ); + const latestCompactionSummary = + latestCompactionBoundary?.summary_preview || "已生成压缩摘要预览"; + const latestTurnPrompt = useMemo(() => { + const activeTurn = + turns.find((turn) => turn.id === currentTurnId) || turns[turns.length - 1]; + return activeTurn?.prompt_text?.trim() || ""; + }, [currentTurnId, turns]); + const teamMemoryShadowMetadata = useMemo( + () => buildTeamMemoryShadowRequestMetadata(teamMemorySnapshot), + [teamMemorySnapshot], + ); + const teamMemoryShadowKey = useMemo(() => { + if (!teamMemoryShadowMetadata) { + return ""; + } + return [ + teamMemoryShadowMetadata.repo_scope, + ...teamMemoryShadowMetadata.entries.map( + (entry) => `${entry.key}:${entry.updated_at}`, + ), + ].join("|"); + }, [teamMemoryShadowMetadata]); + const diagnosticSessionId = diagnosticRuntimeContext?.sessionId?.trim() || ""; + const diagnosticWorkingDir = diagnosticRuntimeContext?.workingDir?.trim() || ""; + + useEffect(() => { + if (!diagnosticSessionId) { + setMemoryPrefetchState({ status: "idle", result: null, error: null }); + setMemoryPrefetchComparison({ + baselineEntry: null, + diff: null, + assessment: null, + }); + return; + } + if (!diagnosticWorkingDir) { + setMemoryPrefetchState({ + status: "error", + result: null, + error: + "当前未绑定工作区,暂时无法预演 rules / working / durable / compaction 记忆命中。", + }); + setMemoryPrefetchComparison({ + baselineEntry: null, + diff: null, + assessment: null, + }); + return; + } + + let cancelled = false; + setMemoryPrefetchState({ + status: "loading", + result: null, + error: null, + }); + + void prefetchContextMemoryForTurn({ + session_id: diagnosticSessionId, + working_dir: diagnosticWorkingDir, + user_message: latestTurnPrompt, + request_metadata: teamMemoryShadowMetadata + ? { + team_memory_shadow: teamMemoryShadowMetadata, + } + : undefined, + }) + .then((result) => { + if (cancelled) { + return; + } + const historyEntries = recordRuntimeMemoryPrefetchHistory({ + sessionId: diagnosticSessionId, + workingDir: diagnosticWorkingDir, + userMessage: latestTurnPrompt || null, + source: "thread_reliability", + result, + }); + setMemoryPrefetchComparison( + resolveMemoryPrefetchComparison(historyEntries, diagnosticWorkingDir), + ); + setMemoryPrefetchState({ + status: "ready", + result, + error: null, + }); + }) + .catch((error) => { + if (cancelled) { + return; + } + setMemoryPrefetchState({ + status: "error", + result: null, + error: + error instanceof Error + ? error.message + : "记忆预取失败,请稍后重试", + }); + setMemoryPrefetchComparison({ + baselineEntry: null, + diff: null, + assessment: null, + }); + }); + + return () => { + cancelled = true; + }; + }, [ + diagnosticSessionId, + diagnosticWorkingDir, + latestTurnPrompt, + teamMemoryShadowKey, + teamMemoryShadowMetadata, + ]); if (!view.shouldRender) { return null; @@ -725,6 +1173,8 @@ export const AgentThreadReliabilityPanel: React.FC< threadItems, messages, harnessState, + memoryPrefetchState, + memoryPrefetchComparison, diagnosticRuntimeContext, }), ); @@ -756,6 +1206,8 @@ export const AgentThreadReliabilityPanel: React.FC< view, harnessState, messages, + memoryPrefetchState, + memoryPrefetchComparison, diagnosticRuntimeContext, }), ), @@ -843,7 +1295,7 @@ export const AgentThreadReliabilityPanel: React.FC<
当前入口属于 `compat` 线程级快速诊断,只覆盖当前 thread 的运行信号。 - 正式交给外部 Claude / Codex 分析时,请优先使用工作台“交接制品 → + 正式交给外部模型分析时,请优先使用工作台“交接制品 → 外部分析交接”的 `analysis-brief.md / analysis-context.json` 主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、存档或二次处理。 @@ -1120,6 +1572,194 @@ export const AgentThreadReliabilityPanel: React.FC<
) : null} + {diagnosticSessionId ? ( + + 在记忆工作台查看 + + ) : undefined + } + /> + ) : null} + + {memoryPrefetchState.status === "ready" && + memoryPrefetchComparison.baselineEntry && + memoryPrefetchComparison.diff ? ( +
+
+
+ 相对最近基线 +
+ + {resolveMemoryPrefetchHistorySourceLabel( + memoryPrefetchComparison.baselineEntry.source, + )} + + + {formatDiagnosticDateTime( + memoryPrefetchComparison.baselineEntry.capturedAt, + ) || "未知时间"} + +
+ {memoryPrefetchComparison.baselineEntry.userMessage ? ( +
+ 基线输入:{memoryPrefetchComparison.baselineEntry.userMessage} +
+ ) : null} + {memoryPrefetchComparison.assessment ? ( +
+
+ + {formatRuntimeMemoryPrefetchHistoryDiffStatusLabel( + memoryPrefetchComparison.assessment.status, + )} + + + {describeRuntimeMemoryPrefetchHistoryDiffAssessment( + memoryPrefetchComparison.assessment, + )} + +
+
+ ) : null} + {memoryPrefetchComparison.diff.changed ? ( + <> +
+ 具体变化 +
+
+ {memoryPrefetchComparison.diff.layerChanges.rulesDelta !== 0 ? ( + + 规则{" "} + {memoryPrefetchComparison.diff.layerChanges.rulesDelta > 0 ? "+" : ""} + {memoryPrefetchComparison.diff.layerChanges.rulesDelta} + + ) : null} + {memoryPrefetchComparison.diff.layerChanges.workingChanged !== "same" ? ( + + 工作 + {memoryPrefetchComparison.diff.layerChanges.workingChanged === "added" + ? " 新命中" + : " 取消命中"} + + ) : null} + {memoryPrefetchComparison.diff.layerChanges.durableDelta !== 0 ? ( + + 持久{" "} + {memoryPrefetchComparison.diff.layerChanges.durableDelta > 0 ? "+" : ""} + {memoryPrefetchComparison.diff.layerChanges.durableDelta} + + ) : null} + {memoryPrefetchComparison.diff.layerChanges.teamDelta !== 0 ? ( + + Team{" "} + {memoryPrefetchComparison.diff.layerChanges.teamDelta > 0 ? "+" : ""} + {memoryPrefetchComparison.diff.layerChanges.teamDelta} + + ) : null} + {memoryPrefetchComparison.diff.layerChanges.compactionChanged !== "same" ? ( + + 压缩 + {memoryPrefetchComparison.diff.layerChanges.compactionChanged === "added" + ? " 新命中" + : " 取消命中"} + + ) : null} +
+ {memoryPrefetchComparison.diff.previewChanges.length > 0 ? ( +
+ {memoryPrefetchComparison.diff.previewChanges + .slice(0, 3) + .map((change, index) => ( +

+ {resolveMemoryPrefetchPreviewChangeLabel(change)} +

+ ))} +
+ ) : null} + + ) : ( +
+ 当前回合与最近基线相比没有明显变化。 +
+ )} +
+ ) : null} + + {latestCompactionBoundary ? ( +
+
+
+ 最近压缩边界 +
+ {latestCompactionBoundary.trigger ? ( + + {latestCompactionBoundary.trigger} + + ) : null} + {typeof latestCompactionBoundary.turn_count === "number" ? ( + + 覆盖 {latestCompactionBoundary.turn_count} 回合 + + ) : null} +
+
+ {latestCompactionSummary} +
+
+ + 生成时间 {latestCompactionCreatedLabel || "未知"} + + {latestCompactionDetail ? ( + 压缩备注 {latestCompactionDetail} + ) : null} +
+
+ ) : null} +
{view.outcome ? ( diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index b644fdd2d..1ac49d81c 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -633,6 +633,9 @@ describe("CanvasWorkbenchLayout", () => { expect( container.querySelector('button[aria-label="切换画布标签-draft.md"]'), ).not.toBeNull(); + expect( + container.querySelectorAll('button[aria-label="切换画布标签-draft.md"]'), + ).toHaveLength(1); expect( container.querySelector('[data-testid="preview-panel"]')?.textContent, ).toContain("default-canvas:draft.md"); diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index e6d51c223..458ff5d71 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -575,13 +575,53 @@ function buildEntries( taskFiles: TaskFile[], workspaceRoot?: string | null, ): CanvasWorkbenchEntry[] { + const taskFileEntries: CanvasWorkbenchTaskFileEntry[] = taskFiles + .slice() + .sort((left, right) => right.updatedAt - left.updatedAt) + .map((taskFile) => ({ + key: `task:${taskFile.id}`, + source: "task-file" as const, + taskFile, + title: resolveContentPostArtifactDisplayTitle({ + title: extractFileNameFromPath(taskFile.name), + filePath: taskFile.name, + metadata: taskFile.metadata, + }), + subtitle: taskFile.name, + filePath: taskFile.name, + absolutePath: resolveAbsoluteWorkspacePath( + workspaceRoot, + taskFile.name, + ), + previewText: taskFile.content?.trim().slice(0, 180), + createdAt: taskFile.updatedAt, + badgeLabel: taskFile.type === "document" ? "文档" : undefined, + kindLabel: "任务文件", + })); + + const taskFilePathSet = new Set( + taskFileEntries + .map((entry) => normalizePath(entry.absolutePath || entry.filePath || "")) + .filter(Boolean), + ); + const seenArtifactPaths = new Set(); + const entries: CanvasWorkbenchEntry[] = artifacts .slice() .reverse() - .map((artifact) => { + .flatMap((artifact) => { const filePath = resolveArtifactProtocolFilePath(artifact); const writePhase = resolveArtifactWritePhase(artifact); - return { + const absolutePath = resolveAbsoluteWorkspacePath(workspaceRoot, filePath); + const pathKey = normalizePath(absolutePath || filePath || ""); + if (pathKey) { + if (taskFilePathSet.has(pathKey) || seenArtifactPaths.has(pathKey)) { + return []; + } + seenArtifactPaths.add(pathKey); + } + + return [{ key: `artifact:${artifact.id}`, source: "artifact", artifact, @@ -592,14 +632,14 @@ function buildEntries( }), subtitle: filePath, filePath, - absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + absolutePath, previewText: resolveArtifactPreviewText(artifact), createdAt: artifact.updatedAt || artifact.createdAt, badgeLabel: writePhase ? formatArtifactWritePhaseLabel(writePhase) : undefined, kindLabel: "产物", - }; + }]; }); if (isDocumentCanvasState(canvasState)) { @@ -630,31 +670,7 @@ function buildEntries( ); } - entries.push( - ...taskFiles - .slice() - .sort((left, right) => right.updatedAt - left.updatedAt) - .map((taskFile) => ({ - key: `task:${taskFile.id}`, - source: "task-file" as const, - taskFile, - title: resolveContentPostArtifactDisplayTitle({ - title: extractFileNameFromPath(taskFile.name), - filePath: taskFile.name, - metadata: taskFile.metadata, - }), - subtitle: taskFile.name, - filePath: taskFile.name, - absolutePath: resolveAbsoluteWorkspacePath( - workspaceRoot, - taskFile.name, - ), - previewText: taskFile.content?.trim().slice(0, 180), - createdAt: taskFile.updatedAt, - badgeLabel: taskFile.type === "document" ? "文档" : undefined, - kindLabel: "任务文件", - })), - ); + entries.push(...taskFileEntries); const seen = new Set(); return entries.filter((entry) => { diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index f0c0cfb35..62e00c3ce 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -5,6 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatSidebar } from "./ChatSidebar"; import type { Topic } from "../hooks/agentChatShared"; import type { Message } from "../types"; +import type { Project } from "@/lib/api/project"; + +const { mockGetProject } = vi.hoisted(() => ({ + mockGetProject: vi.fn<(projectId: string) => Promise>( + async () => null, + ), +})); + +vi.mock("@/lib/api/project", () => ({ + getProject: (projectId: string) => mockGetProject(projectId), +})); vi.mock("@/components/ui/badge", () => ({ Badge: ({ @@ -50,6 +61,8 @@ beforeEach(() => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + mockGetProject.mockReset(); + mockGetProject.mockResolvedValue(null); }); afterEach(() => { @@ -148,6 +161,96 @@ describe("ChatSidebar", () => { ); }); + it("任务中心侧栏应在顶部展示继续上次任务与最近结果入口", async () => { + mockGetProject.mockImplementation(async (projectId: string) => { + if (projectId === "project-waiting") { + return { + id: projectId, + name: "品牌项目", + } as Project; + } + if (projectId === "project-recent") { + return { + id: projectId, + name: "活动项目", + } as Project; + } + return null; + }); + + const onResumeTask = vi.fn(); + const onSwitchTopic = vi.fn(); + const now = Date.now(); + const container = renderSidebar({ + contextVariant: "task-center", + onResumeTask, + onSwitchTopic, + currentTopicId: null, + topics: [ + { + ...defaultTopics[0], + id: "topic-waiting", + title: "待继续任务", + updatedAt: new Date(now), + status: "waiting", + statusReason: "user_action", + lastPreview: "请先确认发布标题后继续。", + workspaceId: "project-waiting", + sourceSessionId: "topic-waiting", + }, + { + ...defaultTopics[0], + id: "topic-recent", + title: "最近回访任务", + updatedAt: new Date(now - 2_000), + status: "done", + lastPreview: "首版结果已经产出,可继续补充和复盘。", + workspaceId: "project-recent", + sourceSessionId: "topic-recent", + }, + ], + }); + + await act(async () => { + await Promise.resolve(); + }); + + const panel = container.querySelector( + '[data-testid="task-center-continuation-panel"]', + ) as HTMLElement | null; + expect(panel).toBeTruthy(); + expect(panel?.textContent).toContain("继续上次任务"); + expect(panel?.textContent).toContain( + "上次推进到哪、结果留在哪个项目里,这里会直接告诉你。", + ); + expect(panel?.textContent).toContain("待继续任务"); + expect(panel?.textContent).toContain("品牌项目"); + expect(panel?.textContent).toContain("最近回访任务"); + expect(panel?.textContent).toContain("活动项目"); + + const primaryButton = container.querySelector( + '[data-testid="task-center-primary-continuation"]', + ) as HTMLButtonElement | null; + expect(primaryButton).toBeTruthy(); + + act(() => { + primaryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onResumeTask).toHaveBeenCalledWith("topic-waiting", "user_action"); + + const relatedButton = container.querySelector( + '[data-testid="task-center-related-topic-recent"]', + ) as HTMLButtonElement | null; + expect(relatedButton).toBeTruthy(); + + act(() => { + relatedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSwitchTopic).toHaveBeenCalledWith("topic-recent"); + }); + it("任务中心侧栏应使用回访型任务分组标题", () => { const now = Date.now(); const container = renderSidebar({ @@ -196,7 +299,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("更早记录"); }); - it("Team Runtime 和任务列表应处于同一滚动区域", () => { + it("任务协作和任务列表应处于同一滚动区域", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -222,11 +325,14 @@ describe("ChatSidebar", () => { expect(scrollArea).toBeTruthy(); expect(teamSection).toBeTruthy(); expect(scrollArea?.contains(teamSection)).toBe(true); - expect(scrollArea?.textContent).toContain("Team Runtime"); + expect(scrollArea?.textContent).toContain("任务协作"); + expect(scrollArea?.textContent).toContain( + "这里展示当前任务的协作成员、当前任务和任务节点。", + ); expect(scrollArea?.textContent).toContain("任务一"); }); - it("点击 Team Runtime 的任务入口应收起顶部区块并滚动到任务列表", () => { + it("点击任务协作的任务入口应收起顶部区块并滚动到任务列表", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -544,7 +650,7 @@ describe("ChatSidebar", () => { onOpenSubagentSession, }); - expect(container.textContent).toContain("Team Runtime"); + expect(container.textContent).toContain("任务协作"); expect(container.textContent).toContain("代码审查代理"); expect(container.textContent).toContain("文档校对代理"); expect(container.textContent).toContain("处理中"); @@ -562,7 +668,7 @@ describe("ChatSidebar", () => { expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1"); }); - it("父线程 Team Runtime 区域应支持折叠和展开", () => { + it("父线程任务协作区域应支持折叠和展开", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -592,7 +698,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("文档校对代理"); const collapseButton = container.querySelector( - 'button[aria-label="收起 Team Runtime"]', + 'button[aria-label="收起任务协作"]', ) as HTMLButtonElement | null; expect(collapseButton).toBeTruthy(); @@ -607,7 +713,7 @@ describe("ChatSidebar", () => { expect(container.textContent).not.toContain("文档校对代理"); const expandButton = container.querySelector( - 'button[aria-label="展开 Team Runtime"]', + 'button[aria-label="展开任务协作"]', ) as HTMLButtonElement | null; expect(expandButton).toBeTruthy(); @@ -619,7 +725,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("文档校对代理"); }); - it("父线程 Team Runtime 在子代理较多时应默认收起,并支持展开更多子代理", () => { + it("父线程任务协作在子代理较多时应默认收起,并支持展开更多子代理", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -674,7 +780,7 @@ describe("ChatSidebar", () => { expect(container.textContent).not.toContain("回归验证代理"); const expandTeamButton = container.querySelector( - 'button[aria-label="展开 Team Runtime"]', + 'button[aria-label="展开任务协作"]', ) as HTMLButtonElement | null; expect(expandTeamButton).toBeTruthy(); @@ -718,7 +824,7 @@ describe("ChatSidebar", () => { expect(container.textContent).not.toContain("回归验证代理"); }); - it("子线程同级子代理较多时应默认收起 Team Runtime", () => { + it("子线程同级子代理较多时应默认收起任务协作", () => { const container = renderSidebar({ topics: [ { @@ -778,7 +884,7 @@ describe("ChatSidebar", () => { expect(container.textContent).not.toContain("文档代理"); const expandButton = container.querySelector( - 'button[aria-label="展开 Team Runtime"]', + 'button[aria-label="展开任务协作"]', ) as HTMLButtonElement | null; expect(expandButton).toBeTruthy(); @@ -828,7 +934,7 @@ describe("ChatSidebar", () => { onReturnToParentSession, }); - expect(container.textContent).toContain("Team Runtime"); + expect(container.textContent).toContain("任务协作"); expect(container.textContent).toContain("主线程"); expect(container.textContent).toContain("当前子代理"); expect(container.textContent).toContain("实现 team sidebar"); diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index 2017e9e70..d2219151b 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -24,6 +24,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import { getProject } from "@/lib/api/project"; import type { AsterSubagentParentContext, AsterSubagentSessionInfo, @@ -42,6 +43,7 @@ const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3; const OLDER_TASKS_INITIAL_COUNT = 8; const TEAM_SECTION_INITIAL_CHILD_COUNT = 3; const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2; +const TEAM_SECTION_LABEL = "任务协作"; const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids"; const STATUS_META: Record< @@ -92,6 +94,7 @@ interface TaskCardViewModel { id: string; title: string; updatedAt: Date; + workspaceId?: string | null; messagesCount: number; status: TaskStatus; statusReason?: TaskStatusReason; @@ -108,6 +111,11 @@ interface TaskSection { items: TaskCardViewModel[]; } +interface TaskCenterContinuationState { + primary: TaskCardViewModel | null; + related: TaskCardViewModel[]; +} + interface ChatSidebarProps { contextVariant?: ChatSidebarContextVariant; onNewChat: () => void; @@ -182,6 +190,87 @@ function sortTaskItems(items: TaskCardViewModel[]) { }); } +function isResumableTask(item: Pick) { + return ( + item.status === "waiting" || + (item.status === "failed" && item.statusReason === "workspace_error") + ); +} + +function resolveTaskCenterContinuationState( + items: TaskCardViewModel[], + currentTopicId: string | null, +): TaskCenterContinuationState { + const sortedItems = sortTaskItems(items); + const resumableItems = sortedItems.filter((item) => isResumableTask(item)); + const recentDoneItems = sortedItems.filter((item) => item.status === "done"); + const currentItem = + sortedItems.find((item) => item.id === currentTopicId) ?? null; + const primary = + (currentItem && isResumableTask(currentItem) ? currentItem : null) ?? + resumableItems[0] ?? + recentDoneItems[0] ?? + currentItem ?? + sortedItems[0] ?? + null; + + const related = [...resumableItems, ...recentDoneItems] + .filter((item) => item.id !== primary?.id) + .filter( + (item, index, array) => + array.findIndex((candidate) => candidate.id === item.id) === index, + ) + .slice(0, 3); + + return { + primary, + related, + }; +} + +function resolveTaskCenterContinuationBadge( + item: TaskCardViewModel, +): string { + if (isResumableTask(item)) { + return "等你继续"; + } + if (item.status === "done") { + return "最近结果"; + } + if (item.status === "running") { + return "正在推进"; + } + return "工作现场"; +} + +function resolveTaskCenterContinuationActionLabel( + item: TaskCardViewModel, +): string { + if (isResumableTask(item)) { + return "继续任务"; + } + if (item.status === "done") { + return "回看结果"; + } + if (item.status === "running") { + return "查看进展"; + } + return "打开任务"; +} + +function areProjectNameMapsEqual( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => left[key] === right[key]); +} + function loadPinnedTaskIds() { if (typeof window === "undefined") { return [] as string[]; @@ -506,6 +595,7 @@ export const ChatSidebar: React.FC = ({ id: topic.id, title: resolveSidebarDisplayTitle(topic.title, "未命名任务"), updatedAt: topic.updatedAt || topic.createdAt, + workspaceId: topic.workspaceId ?? null, messagesCount: topic.messagesCount, status, statusReason, @@ -531,6 +621,34 @@ export const ChatSidebar: React.FC = ({ () => taskItems.find((item) => item.id === currentTopicId) ?? null, [currentTopicId, taskItems], ); + const taskCenterContinuationState = useMemo( + () => + contextVariant === "task-center" + ? resolveTaskCenterContinuationState(taskItems, currentTopicId) + : { primary: null, related: [] }, + [contextVariant, currentTopicId, taskItems], + ); + const continuationProjectIds = useMemo( + () => + Array.from( + new Set( + [ + taskCenterContinuationState.primary, + ...taskCenterContinuationState.related, + ] + .map((item) => item?.workspaceId?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ), + [taskCenterContinuationState], + ); + const continuationProjectIdsKey = useMemo( + () => continuationProjectIds.join("|"), + [continuationProjectIds], + ); + const [continuationProjectNames, setContinuationProjectNames] = useState< + Record + >({}); const sortedChildSubagentSessions = useMemo( () => [...childSubagentSessions].sort( @@ -687,6 +805,55 @@ export const ChatSidebar: React.FC = ({ ); }, [pinnedTaskIds]); + useEffect(() => { + if ( + contextVariant !== "task-center" || + continuationProjectIds.length === 0 + ) { + setContinuationProjectNames((current) => + Object.keys(current).length > 0 ? {} : current, + ); + return; + } + + let cancelled = false; + + const loadProjectNames = async () => { + const entries = await Promise.all( + continuationProjectIds.map(async (projectId) => { + try { + const project = await getProject(projectId); + return [projectId, project?.name?.trim() || null] as const; + } catch { + return [projectId, null] as const; + } + }), + ); + + if (cancelled) { + return; + } + + const nextProjectNames = Object.fromEntries( + entries.filter( + (entry): entry is readonly [string, string] => Boolean(entry[1]), + ), + ); + + setContinuationProjectNames((current) => + areProjectNameMapsEqual(current, nextProjectNames) + ? current + : nextProjectNames, + ); + }; + + void loadProjectNames(); + + return () => { + cancelled = true; + }; + }, [contextVariant, continuationProjectIds, continuationProjectIdsKey]); + const handleDeleteClick = (topicId: string) => { onDeleteTopic(topicId); }; @@ -713,6 +880,24 @@ export const ChatSidebar: React.FC = ({ void onSwitchTopic(item.id); }; + const handleOpenContinuationTask = (item: TaskCardViewModel) => { + if (isResumableTask(item)) { + handleResumeTask(item); + return; + } + + void onSwitchTopic(item.id); + }; + + const resolveTaskProjectLabel = (item: TaskCardViewModel) => { + const projectId = item.workspaceId?.trim(); + if (!projectId) { + return "通用任务"; + } + + return continuationProjectNames[projectId] || "当前项目"; + }; + const handleJumpToTaskSection = () => { setTeamSectionCollapsedOverride(true); setShowAllChildSubagents(false); @@ -870,6 +1055,92 @@ export const ChatSidebar: React.FC = ({ data-testid="chat-sidebar-scroll-area" >
+ {contextVariant === "task-center" && + taskCenterContinuationState.primary ? ( +
+
+
+
+ 继续上次任务 +
+

+ 上次推进到哪、结果留在哪个项目里,这里会直接告诉你。 +

+
+ + 沉淀已保留 + +
+ + + + {taskCenterContinuationState.related.length > 0 ? ( +
+ {taskCenterContinuationState.related.map((item) => ( + + ))} +
+ ) : null} +
+ ) : null} + {shouldShowTeamSection ? (
= ({
- Team Runtime + {TEAM_SECTION_LABEL}

{teamSectionCollapsed ? collapsedTeamSummary : subagentParentContext - ? "当前线程来自父会话,可直接返回主线程并切换同级子代理。" - : "这里展示真实 child session,而不是 synthetic timeline。"} + ? "当前线程来自主任务,可直接返回主任务并切换其他分工。" + : "这里展示当前任务的协作成员、当前任务和任务节点。"}

@@ -914,8 +1185,8 @@ export const ChatSidebar: React.FC = ({ type="button" aria-label={ teamSectionCollapsed - ? "展开 Team Runtime" - : "收起 Team Runtime" + ? `展开${TEAM_SECTION_LABEL}` + : `收起${TEAM_SECTION_LABEL}` } onClick={() => setTeamSectionCollapsedOverride( diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 33d323af8..66b323648 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -6,9 +6,12 @@ import { EmptyState } from "./EmptyState"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import type { ServiceSkillHomeItem } from "../service-skills/types"; +import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; +import { recordEntryRecommendedSolutionUsage } from "../utils/entryRecommendedSolutions"; -const { mockGetConfig } = vi.hoisted(() => ({ +const { mockGetConfig, mockProjectSelector } = vi.hoisted(() => ({ mockGetConfig: vi.fn(async () => ({})), + mockProjectSelector: vi.fn(), })); const mockCharacterMention = @@ -28,6 +31,21 @@ vi.mock("@/lib/api/appConfig", () => ({ getConfig: mockGetConfig, })); +vi.mock("@/components/projects/ProjectSelector", () => ({ + ProjectSelector: (props: { + value: string | null; + placeholder?: string; + onChange?: (projectId: string) => void; + }) => { + mockProjectSelector(props); + return ( +
+ {props.value ?? props.placeholder ?? "选择项目"} +
+ ); + }, +})); + vi.mock("./ChatModelSelector", () => ({ ChatModelSelector: () =>
, })); @@ -236,6 +254,36 @@ function createGithubSearchServiceSkill(): ServiceSkillHomeItem { }; } +function createSceneBoundServiceSkill(): ServiceSkillHomeItem { + return { + id: "project-insight-flow", + title: "项目线索整理", + summary: "围绕当前项目整理线索、结论和下一步动作。", + category: "研究与方案", + outputHint: "线索清单 + 下一步建议", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "cloud_scene", + executionLocation: "cloud_required", + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "云端场景执行", + runnerTone: "emerald", + runnerDescription: "围绕当前项目持续整理线索和结果。", + actionLabel: "继续整理", + automationStatus: null, + slotSchema: [], + sceneBinding: { + sceneKey: "project-insight-flow", + commandPrefix: "/project-insight-flow", + title: "项目线索整理", + summary: "围绕当前项目整理线索和下一步动作。", + }, + }; +} + describe("EmptyState", () => { it("首页应以 slogan 作为主视觉并保留创作语义", async () => { const container = renderEmptyState({ @@ -253,12 +301,12 @@ describe("EmptyState", () => { "文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。", ); expect(container.textContent).toContain( - "跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。", + "跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。", ); expect(container.textContent).not.toContain("新建任务"); }); - it("通用首页应展示精简后的推荐方案条", async () => { + it("通用首页应展示收口后的结果模板条", async () => { const container = renderEmptyState({ activeTheme: "general", onLaunchBrowserAssist: vi.fn(), @@ -269,7 +317,17 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("推荐方案"); + expect(container.textContent).toContain("结果模板"); + expect(container.textContent).toContain("继续上次做法"); + expect(container.textContent).toContain( + "先选你想拿到什么结果,具体由 Lime 在当前任务里分发对应能力。", + ); + expect(container.textContent).toContain( + "最近跑通过的结果模板和常用做法会留在这里,下一次不用重新开始。", + ); + expect(container.textContent).toContain( + "你最近跑通过的结果模板和方法,会出现在这里。", + ); expect(container.textContent).toContain("网页研究简报"); expect(container.textContent).toContain("内容主稿生成"); expect(container.textContent).toContain("演示提纲草案"); @@ -288,6 +346,7 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("生成配图"); expect(container.textContent).not.toContain("浏览器任务起步"); expect(container.textContent).not.toContain("Team 冒烟测试"); + expect(container.textContent).not.toContain("推荐方案"); const recommendationTitles = Array.from( container.querySelectorAll( @@ -303,6 +362,132 @@ describe("EmptyState", () => { "复制轮播帖", "复制视频脚本", ]); + expect( + container.querySelector('[data-testid^="entry-continuation-"]'), + ).toBeNull(); + }); + + it("有最近记录时应展示继续上次做法入口,并允许直接继续模板与方法", async () => { + recordEntryRecommendedSolutionUsage("social-post-starter"); + const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + const setInput = vi.fn<(value: string) => void>(); + const recentMethod: ServiceSkillHomeItem = { + ...createSceneBoundServiceSkill(), + id: "content-iteration-flow", + title: "内容迭代整理", + recentUsedAt: 1_000, + isRecent: true, + }; + + const container = renderEmptyState({ + activeTheme: "general", + setInput, + onSelectServiceSkill, + serviceSkills: [recentMethod], + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("继续上次做法"); + expect(container.textContent).toContain( + "最近跑通过的结果模板和常用做法会留在这里,这次可以直接续上。", + ); + + const continuationItems = Array.from( + container.querySelectorAll('[data-testid^="entry-continuation-"]'), + ).map((element) => element.getAttribute("data-testid")); + + expect(continuationItems).toEqual([ + "entry-continuation-solution-social-post-starter", + "entry-continuation-method-content-iteration-flow", + ]); + + const recentTemplateButton = container.querySelector( + '[data-testid="entry-continuation-solution-social-post-starter"]', + ) as HTMLButtonElement | null; + expect(recentTemplateButton).toBeTruthy(); + + act(() => { + recentTemplateButton?.click(); + }); + + expect(setInput).toHaveBeenCalledWith( + "请先帮我起草一版内容首稿:明确目标受众、标题方向、正文结构和可继续扩写的角度。", + ); + + const recentMethodButton = container.querySelector( + '[data-testid="entry-continuation-method-content-iteration-flow"]', + ) as HTMLButtonElement | null; + expect(recentMethodButton).toBeTruthy(); + + act(() => { + recentMethodButton?.click(); + }); + + expect(onSelectServiceSkill).toHaveBeenCalledWith( + expect.objectContaining({ + id: "content-iteration-flow", + title: "内容迭代整理", + }), + ); + }); + + it("scene 最近使用记录也应把对应做法带回首页继续层", async () => { + recordSlashEntryUsage({ + kind: "scene", + entryId: "project-insight-flow", + usedAt: 1_800_000_000_000, + replayText: "继续帮我整理这个项目的关键信息和后续动作", + }); + const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + const container = renderEmptyState({ + activeTheme: "general", + serviceSkills: [createSceneBoundServiceSkill()], + onSelectServiceSkill, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("继续上次做法"); + const sceneContinuationButton = container.querySelector( + '[data-testid="entry-continuation-method-project-insight-flow"]', + ) as HTMLButtonElement | null; + expect(sceneContinuationButton).toBeTruthy(); + + act(() => { + sceneContinuationButton?.click(); + }); + + expect(onSelectServiceSkill).toHaveBeenCalledWith( + expect.objectContaining({ + id: "project-insight-flow", + title: "项目线索整理", + }), + ); + }); + + it("传入项目切换能力时应继续保留项目选择器入口", async () => { + const container = renderEmptyState({ + activeTheme: "general", + projectId: "project-brand", + onProjectChange: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="project-selector-stub"]'), + ).toBeTruthy(); + expect(container.textContent).not.toContain("当前项目"); + expect( + container.querySelector('[data-testid^="entry-project-continuation-"]'), + ).toBeNull(); }); it("通用首页应继续渲染 4 个带视觉预览的支撑能力入口", async () => { @@ -315,10 +500,10 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const expectedLabels = ["技能", "自动化", "多代理", "浏览器接入"]; + const expectedLabels = ["我的方法", "持续流程", "多代理", "浏览器接入"]; const expectedAlts = [ - "技能能力卡占位图", - "自动化能力卡占位图", + "方法能力卡占位图", + "持续流程能力卡占位图", "多代理协作能力卡占位图", "浏览器接入能力卡占位图", ]; @@ -327,7 +512,7 @@ describe("EmptyState", () => { expect(container.textContent).toContain(label); } - expect(container.textContent).toContain("重复任务可持续推进"); + expect(container.textContent).toContain("重复任务可持续复用"); expect(container.textContent).toContain("复杂任务可拆分并行推进"); expect(container.textContent).toContain("网页登录与网页执行"); @@ -650,7 +835,7 @@ describe("EmptyState", () => { expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react", undefined); }); - it("首页技能卡应复用统一的技能数量文案", async () => { + it("首页方法卡应复用统一的做法数量文案", async () => { const container = renderEmptyState({ skills: [ { @@ -691,7 +876,7 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("2 项技能可挂载"); + expect(container.textContent).toContain("2 套做法可直接复用"); }); it("通用对话且存在站点型 service skill 时,应展示自然句占位示例", async () => { @@ -780,12 +965,18 @@ describe("EmptyState", () => { ) as HTMLButtonElement | null; expect(advancedToggle).toBeTruthy(); - expect(container.querySelector('button[title="深度思考已关闭"]')).toBeNull(); - expect(container.querySelector('button[title="联网搜索已关闭"]')).toBeNull(); + expect( + container.querySelector('button[title="深度思考已关闭"]'), + ).toBeNull(); + expect( + container.querySelector('button[title="联网搜索已关闭"]'), + ).toBeNull(); expect( container.querySelector('[data-testid="inputbar-plan-toggle"]'), ).toBeNull(); - expect(container.querySelector('button[title="多代理偏好已关闭"]')).toBeNull(); + expect( + container.querySelector('button[title="多代理偏好已关闭"]'), + ).toBeNull(); expect( container.querySelector('[data-testid="inputbar-access-mode-select"]'), ).toBeNull(); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index cb718fa62..11e767d4a 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -46,6 +46,7 @@ import { getActiveSkillDisplayLabel, getSkillSelectionSummaryLabel, } from "../skill-selection/skillSelectionDisplay"; +import { listSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import { getSiteSkillAutoLaunchExample, hasAutoLaunchableSiteSkill, @@ -112,10 +113,11 @@ const RecommendationShelfHeader = styled.div` const RecommendationShelfHeaderBody = styled.div` display: flex; - align-items: center; - gap: 0.55rem; + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; min-width: 0; - flex-shrink: 0; + flex: 1 1 auto; `; const RecommendationShelfHeaderTitle = styled.div` @@ -125,6 +127,12 @@ const RecommendationShelfHeaderTitle = styled.div` color: rgb(100 116 139); `; +const RecommendationShelfHeaderDescription = styled.div` + font-size: 11px; + line-height: 1.45; + color: rgb(148 163 184); +`; + const RecommendationShelfList = styled.div` display: flex; align-items: center; @@ -147,6 +155,7 @@ const RecommendationShelfButton = styled.button` display: inline-flex; min-width: 0; align-items: center; + gap: 0.35rem; border: none; background: transparent; padding: 0; @@ -175,6 +184,26 @@ const RecommendationShelfHint = styled.span` color: rgb(148 163 184); `; +const RecommendationShelfBadge = styled.span` + display: inline-flex; + align-items: center; + border-radius: 9999px; + border: 1px solid rgb(209 250 229); + background: rgb(236 253 245); + padding: 0.18rem 0.42rem; + font-size: 10px; + font-weight: 600; + line-height: 1; + color: rgb(5 150 105); +`; + +const RecommendationShelfEmptyState = styled.div` + font-size: 12px; + line-height: 1.5; + color: rgb(148 163 184); + padding: 0.1rem 0; +`; + interface EmptyStateProps extends SkillSelectionSourceProps { input: string; setInput: (value: string) => void; @@ -256,6 +285,16 @@ type RecommendationShelfItem = onSelect: () => void; }; +interface ContinuationShelfItem { + key: string; + title: string; + summary: string; + badge: string; + usedAt: number; + testId: string; + onSelect: () => void; +} + const GENERAL_CATEGORY_LABEL = "通用对话"; const FEATURED_HOME_RECOMMENDED_SOLUTION_IDS = [ "web-research-brief", @@ -290,7 +329,9 @@ function buildSeededFeaturedHomeServiceSkills(): ServiceSkillHomeItem[] { const seededCatalog = getSeededServiceSkillCatalog(); return FEATURED_HOME_SERVICE_SKILL_IDS.flatMap((skillId) => { - const matchedSkill = seededCatalog.items.find((item) => item.id === skillId); + const matchedSkill = seededCatalog.items.find( + (item) => item.id === skillId, + ); if (!matchedSkill) { return []; } @@ -319,7 +360,8 @@ function buildSeededFeaturedHomeServiceSkills(): ServiceSkillHomeItem[] { }); } -const SEEDED_FEATURED_HOME_SERVICE_SKILLS = buildSeededFeaturedHomeServiceSkills(); +const SEEDED_FEATURED_HOME_SERVICE_SKILLS = + buildSeededFeaturedHomeServiceSkills(); // 需要显示创作模式选择器的主题 const CREATION_THEMES: string[] = []; @@ -340,7 +382,7 @@ const THEME_WORKBENCH_COPY: Record< title: "", description: "说一句目标,剩下的交给 Lime。", supportingDescription: - "文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。", + "文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。", }, }; @@ -352,6 +394,32 @@ function truncatePrompt(value: string, maxLength = 92) { return `${normalized.slice(0, maxLength).trim()}…`; } +function compareRecentShelfItems< + T extends { + title: string; + usedAt: number; + }, +>(left: T, right: T): number { + if (left.usedAt !== right.usedAt) { + return right.usedAt - left.usedAt; + } + + return left.title.localeCompare(right.title, "zh-CN"); +} + +function formatMethodSummaryLabel(summaryLabel: string): string { + const countMatch = summaryLabel.match(/^(\d+)\s+项技能可挂载$/); + if (countMatch) { + return `${countMatch[1]} 套做法可直接复用`; + } + + if (summaryLabel === "按需挂载任务能力") { + return "按需挂上常用做法"; + } + + return summaryLabel; +} + export const EmptyState: React.FC = ({ input, setInput, @@ -421,6 +489,7 @@ export const EmptyState: React.FC = ({ activeSkill: currentSkill, skillCount: skillOptionCount, }); + const methodSummaryLabel = formatMethodSummaryLabel(skillSummaryLabel); const hasAutoLaunchSiteSkill = hasAutoLaunchableSiteSkill(serviceSkills); const siteSkillAutoLaunchExample = getSiteSkillAutoLaunchExample(serviceSkills); @@ -465,11 +534,14 @@ export const EmptyState: React.FC = ({ }, []); // 使用外部传入的 activeTheme,如果有 onThemeChange 则使用受控模式 - const handleThemeChange = useCallback((theme: string) => { - if (onThemeChange) { - onThemeChange(theme === "general" ? theme : "general"); - } - }, [onThemeChange]); + const handleThemeChange = useCallback( + (theme: string) => { + if (onThemeChange) { + onThemeChange(theme === "general" ? theme : "general"); + } + }, + [onThemeChange], + ); // 判断当前主题是否需要显示创作模式选择器 const showCreationModeSelector = CREATION_THEMES.includes(activeTheme); @@ -515,6 +587,14 @@ export const EmptyState: React.FC = ({ return listEntryRecommendedSolutions(); }, [entryRecommendedSolutionsVersion]); + const recentSceneUsageBySceneKey = useMemo(() => { + return new Map( + listSlashEntryUsage() + .filter((record) => record.kind === "scene") + .map((record) => [record.entryId, record] as const), + ); + }, []); + const selectedTextPreview = useMemo(() => { const normalized = (recommendationSelectedText || "") .trim() @@ -596,75 +676,76 @@ export const EmptyState: React.FC = ({ : "有什么我可以帮你的?"; }; - const handleApplyRecommendation = useCallback(( - shortLabel: string, - fullPrompt: string, - ) => { - const looksLikeTeamRuntimePrompt = - activeTheme === "general" && - isTeamRuntimeRecommendation(shortLabel, fullPrompt); - if (looksLikeTeamRuntimePrompt) { - onSubagentEnabledChange?.(true); - } + const handleApplyRecommendation = useCallback( + (shortLabel: string, fullPrompt: string) => { + const looksLikeTeamRuntimePrompt = + activeTheme === "general" && + isTeamRuntimeRecommendation(shortLabel, fullPrompt); + if (looksLikeTeamRuntimePrompt) { + onSubagentEnabledChange?.(true); + } - const promptWithSelection = buildRecommendationPrompt( - fullPrompt, - selectedText, + const promptWithSelection = buildRecommendationPrompt( + fullPrompt, + selectedText, + appendSelectedTextToRecommendation, + ); + if (onRecommendationClick) { + onRecommendationClick(shortLabel, promptWithSelection); + return; + } + setInput(promptWithSelection); + }, + [ + activeTheme, appendSelectedTextToRecommendation, - ); - if (onRecommendationClick) { - onRecommendationClick(shortLabel, promptWithSelection); - return; - } - setInput(promptWithSelection); - }, [ - activeTheme, - appendSelectedTextToRecommendation, - onRecommendationClick, - onSubagentEnabledChange, - selectedText, - setInput, - ]); - - const handleApplyEntryRecommendedSolution = useCallback(( - solution: EntryRecommendedSolutionItem, - ) => { - recordEntryRecommendedSolutionUsage(solution.id); - setEntryRecommendedSolutionsVersion((previous) => previous + 1); - - if (solution.shouldEnableWebSearch && !webSearchEnabled) { - onWebSearchEnabledChange?.(true); - } - - if (solution.shouldEnableTeamMode && !subagentEnabled) { - onSubagentEnabledChange?.(true); - } - - if (solution.themeTarget) { - handleThemeChange(solution.themeTarget); - } - - if (solution.shouldLaunchBrowserAssist) { - void onLaunchBrowserAssist?.(); - } - - const promptWithSelection = buildRecommendationPrompt( - solution.prompt, + onRecommendationClick, + onSubagentEnabledChange, selectedText, + setInput, + ], + ); + + const handleApplyEntryRecommendedSolution = useCallback( + (solution: EntryRecommendedSolutionItem) => { + recordEntryRecommendedSolutionUsage(solution.id); + setEntryRecommendedSolutionsVersion((previous) => previous + 1); + + if (solution.shouldEnableWebSearch && !webSearchEnabled) { + onWebSearchEnabledChange?.(true); + } + + if (solution.shouldEnableTeamMode && !subagentEnabled) { + onSubagentEnabledChange?.(true); + } + + if (solution.themeTarget) { + handleThemeChange(solution.themeTarget); + } + + if (solution.shouldLaunchBrowserAssist) { + void onLaunchBrowserAssist?.(); + } + + const promptWithSelection = buildRecommendationPrompt( + solution.prompt, + selectedText, + appendSelectedTextToRecommendation, + ); + setInput(promptWithSelection); + }, + [ appendSelectedTextToRecommendation, - ); - setInput(promptWithSelection); - }, [ - appendSelectedTextToRecommendation, - handleThemeChange, - onLaunchBrowserAssist, - onSubagentEnabledChange, - onWebSearchEnabledChange, - selectedText, - setInput, - subagentEnabled, - webSearchEnabled, - ]); + handleThemeChange, + onLaunchBrowserAssist, + onSubagentEnabledChange, + onWebSearchEnabledChange, + selectedText, + setInput, + subagentEnabled, + webSearchEnabled, + ], + ); const workspaceBadges = useMemo(() => { const badges: Array<{ @@ -735,26 +816,26 @@ export const EmptyState: React.FC = ({ }> = [ { key: "skills", - eyebrow: "支撑能力", - title: "技能", - value: skillSummaryLabel, + eyebrow: "沉淀能力", + title: "我的方法", + value: methodSummaryLabel, description: - "把常用做法沉淀下来,下次遇到同类任务可以直接复用。", + "把跑通过的常用做法沉淀下来,下次遇到同类任务可以直接续上。", icon: , imageSrc: capabilitySkillsPlaceholder, - imageAlt: "技能能力卡占位图", + imageAlt: "方法能力卡占位图", tone: "lime", }, { key: "automation", - eyebrow: "支撑能力", - title: "自动化", - value: planEnabled ? "当前会按步骤推进" : "重复任务可持续推进", + eyebrow: "沉淀能力", + title: "持续流程", + value: planEnabled ? "当前任务会按步骤推进" : "重复任务可持续复用", description: - "适合批量任务和重复流程,让常做的事情持续推进,不再每次从头开始。", + "适合批量任务和重复流程,让一套做法可以自己持续往下跑,不再每次从头开始。", icon: , imageSrc: capabilityAutomationsPlaceholder, - imageAlt: "自动化能力卡占位图", + imageAlt: "持续流程能力卡占位图", tone: "lime", }, { @@ -762,10 +843,10 @@ export const EmptyState: React.FC = ({ eyebrow: "支撑能力", title: "多代理", value: subagentEnabled - ? "当前任务支持协作分工" + ? "当前任务支持分工推进" : "复杂任务可拆分并行推进", description: - "当一个目标需要调研、方案和执行同时推进时,可以拆成协作分工后统一回收结论。", + "当一个目标需要调研、方案和执行同时推进时,可以拆成多个分工后统一回收结论。", icon: , imageSrc: capabilityAgentTeamsPlaceholder, imageAlt: "多代理协作能力卡占位图", @@ -799,8 +880,8 @@ export const EmptyState: React.FC = ({ }, [ browserAssistLoading, planEnabled, + methodSummaryLabel, onLaunchBrowserAssist, - skillSummaryLabel, subagentEnabled, ]); @@ -840,26 +921,25 @@ export const EmptyState: React.FC = ({ const serviceSkillRecommendations = isGeneralTheme ? featuredServiceSkills.map((skill) => { - const requiresSlots = skill.slotSchema.some( - (slot) => slot.required, - ); + const requiresSlots = skill.slotSchema.some((slot) => slot.required); - return { - kind: "service-skill" as const, - key: `service-skill-${skill.id}`, - title: skill.title, - summary: - skill.summary?.trim() || resolveServiceSkillEntryDescription(skill), - badge: skill.isRecent ? "最近使用" : skill.badge, - hint: requiresSlots - ? "对话内补参后开始" - : `${skill.actionLabel} · 当前对话继续`, - testId: `entry-service-skill-${skill.id}`, - onSelect: () => { - onSelectServiceSkill?.(skill); - }, - }; - }) + return { + kind: "service-skill" as const, + key: `service-skill-${skill.id}`, + title: skill.title, + summary: + skill.summary?.trim() || + resolveServiceSkillEntryDescription(skill), + badge: skill.isRecent ? "最近使用" : skill.badge, + hint: requiresSlots + ? "对话内补参后开始" + : `${skill.actionLabel} · 当前对话继续`, + testId: `entry-service-skill-${skill.id}`, + onSelect: () => { + onSelectServiceSkill?.(skill); + }, + }; + }) : []; return [...solutionRecommendations, ...serviceSkillRecommendations]; @@ -871,6 +951,64 @@ export const EmptyState: React.FC = ({ serviceSkills, ]); + const continuationShelfItems = useMemo(() => { + const recentSolutionItems = entryRecommendedSolutions + .filter( + (solution) => + solution.isRecent && typeof solution.recentUsedAt === "number", + ) + .map((solution) => ({ + key: `solution-${solution.id}`, + title: solution.title, + summary: solution.summary, + badge: "结果模板", + usedAt: solution.recentUsedAt as number, + testId: `entry-continuation-solution-${solution.id}`, + onSelect: () => handleApplyEntryRecommendedSolution(solution), + })); + + const recentMethodItems = + typeof onSelectServiceSkill === "function" + ? (serviceSkills ?? []) + .map((skill) => { + const serviceSkillUsedAt = + typeof skill.recentUsedAt === "number" ? skill.recentUsedAt : 0; + const sceneUsedAt = skill.sceneBinding?.sceneKey + ? (recentSceneUsageBySceneKey.get(skill.sceneBinding.sceneKey) + ?.usedAt ?? 0) + : 0; + const usedAt = Math.max(serviceSkillUsedAt, sceneUsedAt); + + if (usedAt <= 0) { + return null; + } + + return { + key: `method-${skill.id}`, + title: skill.title, + summary: resolveServiceSkillEntryDescription(skill), + badge: "我的方法", + usedAt, + testId: `entry-continuation-method-${skill.id}`, + onSelect: () => { + onSelectServiceSkill(skill); + }, + }; + }) + .filter((item): item is ContinuationShelfItem => item !== null) + : []; + + return [...recentSolutionItems, ...recentMethodItems] + .sort(compareRecentShelfItems) + .slice(0, 4); + }, [ + entryRecommendedSolutions, + handleApplyEntryRecommendedSolution, + onSelectServiceSkill, + recentSceneUsageBySceneKey, + serviceSkills, + ]); + const quickActionItems = useMemo( () => currentRecommendations.slice(0, 4).map(([shortLabel, fullPrompt]) => ({ @@ -1001,8 +1139,11 @@ export const EmptyState: React.FC = ({ - 推荐方案 + 结果模板 + + 先选你想拿到什么结果,具体由 Lime 在当前任务里分发对应能力。 + {selectedTextPreview ? ( @@ -1015,7 +1156,9 @@ export const EmptyState: React.FC = ({ {recommendationShelfItems.map((item, index) => ( {index > 0 ? ( - + ) : null} = ({ ); + const generalContinuationPanel = ( + + + + + 继续上次做法 + + + {continuationShelfItems.length > 0 + ? "最近跑通过的结果模板和常用做法会留在这里,这次可以直接续上。" + : "最近跑通过的结果模板和常用做法会留在这里,下一次不用重新开始。"} + + + + + {continuationShelfItems.length > 0 ? ( + + {continuationShelfItems.map((item, index) => ( + + {index > 0 ? ( + + ) : null} + { + item.onSelect(); + }} + > + + {item.badge} + + + {item.title} + + + + ))} + + ) : ( + + 你最近跑通过的结果模板和方法,会出现在这里。 + + )} + + ); + const headerControls = onProjectChange ? (
@@ -1084,9 +1277,14 @@ export const EmptyState: React.FC = ({ cards={workspaceCards} prioritySlot={composerPanel} supportingSlot={ - isGeneralTheme - ? generalRecommendedSolutionsPanel - : defaultQuickActionsPanel + isGeneralTheme ? ( + <> + {generalContinuationPanel} + {generalRecommendedSolutionsPanel} + + ) : ( + defaultQuickActionsPanel + ) } headerControls={headerControls} /> diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 9da97a504..4109a7321 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -378,7 +378,7 @@ describe("EmptyStateComposerPanel", () => { expect(onRemoveImage).toHaveBeenCalledWith(0); }); - it("复杂任务应显示 Team 建议并支持开启多代理", () => { + it("复杂任务应显示任务分工建议并支持开启多代理", () => { const onSubagentEnabledChange = vi.fn(); const container = renderPanel({ isGeneralTheme: true, @@ -387,12 +387,12 @@ describe("EmptyStateComposerPanel", () => { onSubagentEnabledChange, }); - expect(container.textContent).toContain("当前任务更适合 Team 协作"); + expect(container.textContent).toContain("当前任务更适合分工推进"); expect(container.textContent).toContain("建议角色:分析"); const enableTeamButton = Array.from( container.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("启用 Team")); + ).find((button) => button.textContent?.includes("启用任务分工")); expect(enableTeamButton).toBeTruthy(); @@ -405,7 +405,7 @@ describe("EmptyStateComposerPanel", () => { expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); }); - it("继续单代理后应隐藏当前输入对应的 Team 建议", () => { + it("继续单代理后应隐藏当前输入对应的任务分工建议", () => { const container = renderPanel({ isGeneralTheme: true, input: @@ -423,7 +423,7 @@ describe("EmptyStateComposerPanel", () => { continueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(container.textContent).not.toContain("当前任务更适合 Team 协作"); + expect(container.textContent).not.toContain("当前任务更适合分工推进"); }); it("开启 Team mode 后应显示 TeamSelector", () => { @@ -603,7 +603,7 @@ describe("EmptyStateComposerPanel", () => { }); const enableButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("启用 Team"), + (button) => button.textContent?.includes("启用任务分工"), ) as HTMLButtonElement | undefined; expect( @@ -615,8 +615,8 @@ describe("EmptyStateComposerPanel", () => { container.querySelector('button[title="多代理偏好已关闭"]'), ).toBeNull(); expect(enableButton).toBeTruthy(); - expect(enableButton?.textContent).toContain("启用 Team"); - expect(container.textContent).toContain("当前任务更适合 Team 协作"); + expect(enableButton?.textContent).toContain("启用任务分工"); + expect(container.textContent).toContain("当前任务更适合分工推进"); }); it("折叠态应保留当前模型轻提示,展开高级设置后再允许修改", () => { diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx index 7262e6592..347aab7b1 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx @@ -177,6 +177,46 @@ function renderSidebar( } describe("GeneralWorkbenchSidebar", () => { + it("任务进行时面板应突出当前任务并按状态优先级排序", () => { + const { container } = renderSidebar({ + workflowSteps: [ + { id: "done", title: "完成提纲", status: "completed" }, + { id: "pending", title: "等待补充案例", status: "pending" }, + { id: "active", title: "撰写主稿", status: "active" }, + { id: "error", title: "封面生成失败", status: "error" }, + ], + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开任务进行时面板"]', + ) as HTMLButtonElement | null; + expect(workflowTab).toBeTruthy(); + expect(workflowTab?.textContent).toContain("任务"); + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + expect(container.textContent).toContain("任务工作台"); + expect(container.textContent).toContain("跟踪当前任务、后续节点与产物版本。"); + expect(container.textContent).toContain("任务进行时"); + expect(container.textContent).toContain("当前任务"); + expect(container.textContent).toContain("撰写主稿"); + expect(container.textContent).toContain("已完成 1/4"); + + const stepNodes = Array.from( + container.querySelectorAll('[data-testid="workflow-sidebar-step"]'), + ); + expect(stepNodes).toHaveLength(4); + expect(stepNodes.map((node) => node.getAttribute("data-status"))).toEqual([ + "active", + "error", + "pending", + "completed", + ]); + }); + it("传入折叠回调时应显示折叠按钮并可触发", () => { const onRequestCollapse = vi.fn(); const { container } = renderSidebar({ onRequestCollapse }); @@ -277,7 +317,7 @@ describe("GeneralWorkbenchSidebar", () => { container.querySelector('button[aria-label="打开上下文管理"]'), ).toBeTruthy(); expect( - container.querySelector('button[aria-label="打开编排面板"]'), + container.querySelector('button[aria-label="打开任务进行时面板"]'), ).toBeTruthy(); expect( container.querySelector('button[aria-label="打开执行日志"]'), @@ -685,7 +725,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -713,7 +753,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -740,7 +780,7 @@ describe("GeneralWorkbenchSidebar", () => { it("活动日志应展示后端闸门与运行标识", () => { const { container } = renderSidebar(); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -793,7 +833,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -826,7 +866,7 @@ describe("GeneralWorkbenchSidebar", () => { const onViewRunDetail = vi.fn(); const { container } = renderSidebar({ onViewRunDetail }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -876,7 +916,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -918,7 +958,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -993,7 +1033,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1099,7 +1139,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1182,7 +1222,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1239,7 +1279,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开编排面板"]', + 'button[aria-label="打开任务进行时面板"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx index 30f81fc4f..2e6976b94 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx @@ -74,16 +74,25 @@ function resolveSidebarTitle( if (activeTab === "context") { return "上下文管理"; } - return isVersionMode ? "编排与版本" : "编排与分支"; + if (activeTab === "workflow") { + return "任务进行时"; + } + return isVersionMode ? "版本记录" : "运行记录"; } function resolveSidebarDescription( activeTab: GeneralWorkbenchSidebarTab, + isVersionMode: boolean, ): string { if (activeTab === "context") { return "检索、筛选并启用当前创作真正会用到的上下文。"; } - return "跟踪编排进度、产物版本与运行记录。"; + if (activeTab === "workflow") { + return isVersionMode + ? "跟踪当前任务、后续节点与产物版本。" + : "跟踪当前任务、后续节点与分支进展。"; + } + return "查看技能调用、工具输出与运行记录。"; } export function GeneralWorkbenchSidebarShell({ @@ -105,7 +114,7 @@ export function GeneralWorkbenchSidebarShell({ >
-
工作区编排
+
任务工作台
{headerActionSlot ? (
- {resolveSidebarDescription(activeTab)} + {resolveSidebarDescription(activeTab, isVersionMode)}
把 handoff / evidence / replay 主链重新包装成外部 - Claude / Codex - 可直接消费的分析交接;复制后可直接粘贴给 AI, + AI 可直接消费的分析交接;复制后可直接粘贴给 AI, 不需要你再手写补充 prompt。
@@ -3788,7 +4014,7 @@ export function HarnessStatusPanel({
尚未导出外部分析交接。点击“一键复制给 AI”时会自动先导出再复制, 用于把当前 Lime - 证据链直接交给外部 Claude / Codex 做诊断与最小修复。 + 证据链直接交给外部 AI 做诊断与最小修复。
)}
@@ -3801,10 +4027,8 @@ export function HarnessStatusPanel({ 人工审核记录
- 把外部 Claude / Codex 的分析结论回挂为 - `review-decision.md/json` - 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 - Lime 自动闭环。 + 把外部 AI 的分析结论回挂为 + `review-decision.md/json` 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 Lime 自动闭环。
@@ -4304,8 +4528,10 @@ export function HarnessStatusPanel({ onResumeThread={onResumeThread} onReplayPendingRequest={onReplayPendingRequest} onPromoteQueuedTurn={onPromoteQueuedTurn} + onOpenMemoryWorkbench={onOpenMemoryWorkbench} harnessState={harnessState} messages={messages} + teamMemorySnapshot={teamMemorySnapshot} diagnosticRuntimeContext={diagnosticRuntimeContext} /> diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 5e67603e2..828055670 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -44,6 +44,12 @@ interface InputbarComposerSectionProps { workflowGate?: WorkflowGateState | null; workflowQuickActions: WorkflowQuickAction[]; workflowQueueItems: WorkflowStep[]; + workflowActiveItem: WorkflowStep | null; + workflowQueueTotalCount: number; + workflowCompletedCount: number; + workflowTotalCount: number; + workflowProgressLabel: string; + workflowSummaryLabel: string; inputAdapter: ChatInputAdapter; characters: Character[]; skillSelection: SkillSelectionProps; @@ -86,6 +92,12 @@ export const InputbarComposerSection: React.FC< workflowGate, workflowQuickActions, workflowQueueItems, + workflowActiveItem, + workflowQueueTotalCount, + workflowCompletedCount, + workflowTotalCount, + workflowProgressLabel, + workflowSummaryLabel, inputAdapter, characters, skillSelection, @@ -283,6 +295,12 @@ export const InputbarComposerSection: React.FC< gate={workflowGate} quickActions={workflowQuickActions} queueItems={workflowQueueItems} + activeItem={workflowActiveItem} + queueTotalCount={workflowQueueTotalCount} + completedCount={workflowCompletedCount} + totalCount={workflowTotalCount} + progressLabel={workflowProgressLabel} + summaryLabel={workflowSummaryLabel} renderGeneratingPanel onQuickAction={inputAdapter.actions.setText} onStop={inputAdapter.actions.stop} @@ -296,6 +314,12 @@ export const InputbarComposerSection: React.FC< gate={workflowGate} quickActions={workflowQuickActions} queueItems={workflowQueueItems} + activeItem={workflowActiveItem} + queueTotalCount={workflowQueueTotalCount} + completedCount={workflowCompletedCount} + totalCount={workflowTotalCount} + progressLabel={workflowProgressLabel} + summaryLabel={workflowSummaryLabel} renderGeneratingPanel={false} onQuickAction={inputAdapter.actions.setText} onStop={inputAdapter.actions.stop} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx index 61f62a382..6ad5a5bce 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx @@ -1,10 +1,9 @@ import { useState } from "react"; import { - ChevronDown, - Loader2, - Clock3, AlertCircle, - Sparkles, + ChevronDown, + Clock3, + Loader2, } from "lucide-react"; import styled from "styled-components"; import type { @@ -17,141 +16,161 @@ interface InputbarWorkflowStatusPanelProps { gate?: WorkflowGateState | null; quickActions: WorkflowQuickAction[]; queueItems: WorkflowStep[]; + activeItem: WorkflowStep | null; + queueTotalCount: number; + completedCount: number; + totalCount: number; + progressLabel: string; + summaryLabel: string; renderGeneratingPanel: boolean; onQuickAction: (prompt: string) => void; onStop?: () => void; } -const GateStrip = styled.div` - margin: 0 12px 8px; +type WorkflowTone = "active" | "pending" | "error"; + +const PanelWrap = styled.div` + margin: 0 10px 10px; +`; + +const StatusCard = styled.div` + border: 1px solid hsl(var(--border) / 0.84); + border-radius: 18px; + background: hsl(var(--background)); + box-shadow: 0 10px 24px hsl(var(--foreground) / 0.05); + overflow: hidden; +`; + +const SummaryRow = styled.div` display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px 10px; - padding: 8px 10px; - border-radius: 14px; - border: 1px solid hsl(var(--border) / 0.92); - background: hsl(var(--muted) / 0.78); - box-shadow: none; - opacity: 1; - - @media (prefers-color-scheme: dark) { - background: hsl(222 18% 14% / 0.96); - border-color: hsl(217 18% 24% / 0.95); - } + align-items: flex-start; + gap: 10px; + padding: 12px; `; -const GateMeta = styled.div` - min-width: 0; - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -`; - -const GateIcon = styled.span` - width: 22px; - height: 22px; +const SummaryIcon = styled.span<{ $tone: WorkflowTone }>` + width: 32px; + height: 32px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; - background: hsl(var(--background)); + flex-shrink: 0; + background: ${({ $tone }) => + $tone === "error" + ? "hsl(var(--destructive) / 0.1)" + : $tone === "pending" + ? "hsl(38 100% 92%)" + : "hsl(var(--primary) / 0.12)"}; + color: ${({ $tone }) => + $tone === "error" + ? "hsl(var(--destructive))" + : $tone === "pending" + ? "hsl(30 90% 42%)" + : "hsl(var(--primary))"}; +`; + +const SummaryBody = styled.div` + min-width: 0; + flex: 1; +`; + +const SummaryEyebrow = styled.div` + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; color: hsl(var(--muted-foreground)); - border: 1px solid hsl(var(--border) / 0.9); +`; + +const SummaryTitle = styled.div` + margin-top: 2px; + font-size: 15px; + line-height: 1.35; + font-weight: 600; + color: hsl(var(--foreground)); + word-break: break-word; +`; + +const SummaryDescription = styled.div` + margin-top: 4px; + font-size: 12px; + line-height: 1.45; + color: hsl(var(--muted-foreground)); +`; + +const SummaryAside = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; flex-shrink: 0; `; -const GateTitle = styled.span` - font-size: 12px; - color: hsl(var(--foreground) / 0.86); - font-weight: 600; - line-height: 1.4; -`; - -const GateStatus = styled.span<{ - $status: "running" | "waiting" | "idle"; -}>` - font-size: 11px; - line-height: 1; - border-radius: 999px; - padding: 4px 8px; - color: ${({ $status }) => - $status === "waiting" - ? "hsl(var(--destructive))" - : $status === "running" - ? "hsl(var(--primary))" - : "hsl(var(--muted-foreground))"}; - background: ${({ $status }) => - $status === "waiting" - ? "hsl(var(--destructive) / 0.08)" - : $status === "running" - ? "hsl(var(--primary) / 0.1)" - : "hsl(var(--muted) / 0.7)"}; -`; - -const QuickActions = styled.div` - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-left: auto; -`; - -const QuickButton = styled.button` - border: 1px solid hsl(var(--border) / 0.88); - border-radius: 999px; - background: hsl(var(--background)); - color: hsl(var(--foreground) / 0.82); - font-size: 11px; - line-height: 1.2; - padding: 5px 10px; - cursor: pointer; - - &:hover { - border-color: hsl(var(--primary) / 0.22); - color: hsl(var(--foreground)); - background: hsl(var(--background)); - } -`; - -const GeneratingWrap = styled.div` - margin: 0 10px 10px; - display: flex; - flex-direction: column; - gap: 10px; -`; - -const TaskCard = styled.div` - border: 1px solid hsl(var(--border) / 0.78); - border-radius: 15px; - background: hsl(var(--background)); - box-shadow: 0 8px 20px hsl(var(--foreground) / 0.05); - padding: 11px 12px 10px; -`; - -const TaskHead = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - font-size: 12px; - font-weight: 500; - color: hsl(var(--muted-foreground)); - margin-bottom: 8px; -`; - -const TaskHeadButton = styled.button` +const SummaryStatus = styled.span<{ $tone: WorkflowTone }>` display: inline-flex; align-items: center; - gap: 6px; - border: none; - background: transparent; - color: inherit; - padding: 0; - cursor: pointer; + border-radius: 999px; + padding: 5px 10px; + font-size: 11px; + line-height: 1; + font-weight: 600; + color: ${({ $tone }) => + $tone === "error" + ? "hsl(var(--destructive))" + : $tone === "pending" + ? "hsl(35 95% 35%)" + : "hsl(var(--primary))"}; + background: ${({ $tone }) => + $tone === "error" + ? "hsl(var(--destructive) / 0.12)" + : $tone === "pending" + ? "hsl(36 100% 90%)" + : "hsl(var(--primary) / 0.14)"}; `; -const TaskHeadChevron = styled.span<{ $collapsed: boolean }>` +const QueueSection = styled.div` + border-top: 1px solid hsl(var(--border) / 0.66); + padding: 0 12px 12px; +`; + +const QueueToggleButton = styled.button` + width: 100%; + border: none; + background: transparent; + padding: 10px 0 0; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: hsl(var(--muted-foreground)); +`; + +const QueueToggleLabel = styled.span` + font-size: 12px; + font-weight: 600; + color: hsl(var(--foreground) / 0.88); +`; + +const QueueCount = styled.span` + min-width: 18px; + height: 18px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 6px; + background: hsl(var(--muted) / 0.75); + color: hsl(var(--muted-foreground)); + font-size: 10px; + font-weight: 700; +`; + +const QueueHint = styled.span` + margin-left: auto; + font-size: 11px; + color: hsl(var(--muted-foreground)); +`; + +const QueueChevron = styled.span<{ $collapsed: boolean }>` display: inline-flex; transition: transform 0.2s ease; transform: ${({ $collapsed }) => @@ -161,106 +180,87 @@ const TaskHeadChevron = styled.span<{ $collapsed: boolean }>` const TaskList = styled.div` display: flex; flex-direction: column; - gap: 8px; + gap: 7px; + margin-top: 10px; `; const TaskRow = styled.div` display: flex; - align-items: center; + align-items: flex-start; gap: 10px; - min-height: 34px; min-width: 0; + border-radius: 13px; + border: 1px solid hsl(var(--border) / 0.74); + background: hsl(var(--muted) / 0.2); + padding: 9px 10px; `; -const TaskIcon = styled.span<{ $kind: "active" | "pending" | "error" }>` - width: 30px; - height: 30px; +const TaskIcon = styled.span<{ $tone: WorkflowTone }>` + width: 24px; + height: 24px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; - background: ${({ $kind }) => - $kind === "active" - ? "hsl(var(--primary) / 0.12)" - : $kind === "error" - ? "hsl(var(--destructive) / 0.1)" - : "hsl(38 100% 92%)"}; - color: ${({ $kind }) => - $kind === "active" - ? "hsl(var(--primary))" - : $kind === "error" - ? "hsl(var(--destructive))" - : "hsl(30 90% 42%)"}; flex-shrink: 0; + background: ${({ $tone }) => + $tone === "error" + ? "hsl(var(--destructive) / 0.1)" + : $tone === "pending" + ? "hsl(38 100% 92%)" + : "hsl(var(--primary) / 0.12)"}; + color: ${({ $tone }) => + $tone === "error" + ? "hsl(var(--destructive))" + : $tone === "pending" + ? "hsl(30 90% 42%)" + : "hsl(var(--primary))"}; `; -const TaskText = styled.span` - flex: 1; - font-size: 14px; - color: hsl(var(--foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const TaskStatus = styled.span<{ $kind: "active" | "pending" | "error" }>` - font-size: 11px; - border-radius: 999px; - padding: 4px 10px; - line-height: 1; - font-weight: 600; - color: ${(props) => - props.$kind === "active" - ? "hsl(var(--primary))" - : props.$kind === "error" - ? "hsl(var(--destructive))" - : "hsl(35 95% 35%)"}; - background: ${(props) => - props.$kind === "active" - ? "hsl(var(--primary) / 0.14)" - : props.$kind === "error" - ? "hsl(var(--destructive) / 0.12)" - : "hsl(36 100% 90%)"}; -`; - -const RunningBar = styled.div` - min-height: 44px; - border: 1px solid hsl(var(--border)); - border-radius: 11px; - background: hsl(var(--background)); - box-shadow: 0 4px 14px hsl(var(--foreground) / 0.04); - display: flex; - align-items: center; - gap: 7px; - padding: 7px 10px; -`; - -const RunningIcon = styled.span` - color: hsl(var(--primary)); - display: inline-flex; - flex-shrink: 0; -`; - -const RunningSub = styled.span` - flex: 1; +const TaskBody = styled.div` min-width: 0; - font-size: 12px; - color: hsl(var(--muted-foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + flex: 1; `; -const RunningMain = styled.span` - color: hsl(var(--primary)); - font-weight: 600; - margin-right: 2px; - font-size: 14px; +const TaskTitle = styled.div` + font-size: 13px; + line-height: 1.4; + color: hsl(var(--foreground)); + word-break: break-word; +`; + +const TaskMeta = styled.div` + margin-top: 3px; + font-size: 11px; + color: hsl(var(--muted-foreground)); +`; + +const QuickActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 12px 12px; +`; + +const QuickButton = styled.button` + border: 1px solid hsl(var(--border) / 0.88); + border-radius: 999px; + background: hsl(var(--background)); + color: hsl(var(--foreground) / 0.82); + font-size: 11px; + line-height: 1.2; + padding: 6px 11px; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary) / 0.22); + color: hsl(var(--foreground)); + } `; const StopButton = styled.button` - width: 24px; - height: 24px; + width: 26px; + height: 26px; border-radius: 999px; border: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.28); @@ -269,7 +269,6 @@ const StopButton = styled.button` justify-content: center; color: hsl(var(--muted-foreground)); flex-shrink: 0; - position: relative; &:hover { color: hsl(var(--destructive)); @@ -296,127 +295,173 @@ const StopGlyph = styled.span` } `; +function resolveWorkflowTone( + status: WorkflowStep["status"] | WorkflowGateState["status"] | undefined, +): WorkflowTone { + if (status === "error") { + return "error"; + } + if (status === "pending" || status === "waiting") { + return "pending"; + } + return "active"; +} + +function getStatusLabel(tone: WorkflowTone): string { + if (tone === "error") { + return "异常"; + } + if (tone === "pending") { + return "待处理"; + } + return "进行中"; +} + +function renderToneIcon(tone: WorkflowTone) { + if (tone === "error") { + return ; + } + if (tone === "pending") { + return ; + } + return ; +} + export function InputbarWorkflowStatusPanel({ gate, quickActions, queueItems, + activeItem, + queueTotalCount, + completedCount, + totalCount, + progressLabel, + summaryLabel, renderGeneratingPanel, onQuickAction, onStop, }: InputbarWorkflowStatusPanelProps) { const [queueCollapsed, setQueueCollapsed] = useState(false); - if (renderGeneratingPanel) { - return ( - - - - setQueueCollapsed((prev) => !prev)} - aria-label={queueCollapsed ? "展开待办列表" : "折叠待办列表"} - > - 当前待办 - - - - - - {!queueCollapsed ? ( - - {queueItems.length === 0 ? ( - - - - - 正在编排任务节点... - 进行中 - - ) : ( - queueItems.map((item) => { - const statusKind = - item.status === "active" - ? "active" - : item.status === "error" - ? "error" - : "pending"; - return ( - - - {statusKind === "active" ? ( - - ) : statusKind === "error" ? ( - - ) : ( - - )} - - {item.title} - - {statusKind === "active" - ? "进行中" - : statusKind === "error" - ? "异常" - : "待处理"} - - - ); - }) - )} - - ) : null} - - - - - - 正在生成中 • • • - 切换项目或关闭网页将中断任务 - onStop?.()} - aria-label="停止生成" - > - - - - - ); - } - - if (!gate || gate.status === "idle") { + if (!renderGeneratingPanel && (!gate || gate.status === "idle")) { return null; } + const summaryTone = renderGeneratingPanel + ? resolveWorkflowTone(activeItem?.status) + : resolveWorkflowTone(gate?.status); + const summaryTitle = renderGeneratingPanel + ? activeItem?.title || "正在编排任务节点" + : gate?.title || "等待继续"; + const queueHiddenCount = Math.max(queueTotalCount - queueItems.length, 0); + const queueHintLabel = + queueHiddenCount > 0 + ? `${progressLabel} · 另有 ${queueHiddenCount} 项收纳` + : progressLabel; + return ( - - - - - - {gate.title} - - {gate.status === "waiting" - ? "等待决策" - : gate.status === "running" - ? "自动执行中" - : "待启动"} - - - {quickActions.length > 0 ? ( - - {quickActions.map((action) => ( - + + + {renderToneIcon(summaryTone)} + + + 任务进行时 + + + {summaryTitle} + + {summaryLabel} + + + + {renderGeneratingPanel + ? getStatusLabel(summaryTone) + : gate?.status === "waiting" + ? "等待决策" + : gate?.status === "running" + ? "自动执行中" + : "待启动"} + + {renderGeneratingPanel ? ( + onStop?.()} + aria-label="停止生成" + > + + + ) : null} + + + + {renderGeneratingPanel && queueTotalCount > 0 ? ( + + onQuickAction(action.prompt)} + onClick={() => setQueueCollapsed((previous) => !previous)} + aria-label={queueCollapsed ? "展开任务队列" : "折叠任务队列"} > - {action.label} - - ))} - - ) : null} - + 任务队列 + {queueTotalCount} + {queueHintLabel} + + + + + {!queueCollapsed ? ( + + {queueItems.length === 0 ? ( + + + + + + 正在编排任务节点... + 进行中 + + + ) : ( + queueItems.map((item) => { + const tone = resolveWorkflowTone(item.status); + return ( + + {renderToneIcon(tone)} + + {item.title} + + {getStatusLabel(tone)} + {totalCount > 0 ? ` · ${completedCount}/${totalCount}` : ""} + + + + ); + }) + )} + + ) : null} + + ) : null} + + {!renderGeneratingPanel && quickActions.length > 0 ? ( + + {quickActions.map((action) => ( + onQuickAction(action.prompt)} + > + {action.label} + + ))} + + ) : null} + + ); } diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx index e8f259f14..a4db7c435 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx @@ -46,8 +46,8 @@ export const TeamSelector: React.FC = ({ }, [autoOpenToken]); const resolvedLabel = selectedTeam?.label?.trim() - ? `Team · ${selectedTeam.label.trim()}` - : "配置 Team"; + ? `分工 · ${selectedTeam.label.trim()}` + : "配置分工"; const selectedRoleCount = selectedTeam?.roles.length || 0; diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx index 6a9441248..f14c25956 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx @@ -95,15 +95,15 @@ describe("TeamSelectorPanel", () => { vi.clearAllMocks(); }); - it("不应再展示模型生成 Team 入口", async () => { + it("不应再展示模型生成分工入口", async () => { const { container } = renderPanel(); await flushEffects(); - expect(container.textContent).not.toContain("模型生成 Team"); + expect(container.textContent).not.toContain("模型生成分工"); }); - it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => { + it("应保存自定义分工的 profileId、roleKey 与 skillIds", async () => { const onSelectTeam = vi.fn(); const selectedTeam = createTeamDefinitionFromPreset( "code-triage-team", @@ -116,7 +116,7 @@ describe("TeamSelectorPanel", () => { await flushEffects(); const createButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("新建自定义 Team"), + (button) => button.textContent?.includes("新建自定义分工"), ); expect(createButton).toBeTruthy(); @@ -147,7 +147,7 @@ describe("TeamSelectorPanel", () => { ); const saveButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("保存 Team"), + (button) => button.textContent?.includes("保存分工"), ); expect(saveButton).toBeTruthy(); @@ -173,7 +173,7 @@ describe("TeamSelectorPanel", () => { expect(mockToast.success).toHaveBeenCalled(); }); - it("项目级自定义 Team 应通过回调持久化", async () => { + it("项目级自定义分工应通过回调持久化", async () => { const onPersistCustomTeams = vi.fn().mockResolvedValue(undefined); const workspaceSettings: WorkspaceSettings = { agentTeam: { @@ -201,10 +201,10 @@ describe("TeamSelectorPanel", () => { await flushEffects(); expect(container.textContent).toContain("项目联调 Team"); - expect(container.textContent).toContain("当前项目 Team"); + expect(container.textContent).toContain("当前项目分工"); const deleteButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("删除这个 Team"), + (button) => button.textContent?.includes("删除这套分工"), ); expect(deleteButton).toBeTruthy(); @@ -219,7 +219,7 @@ describe("TeamSelectorPanel", () => { expect(mockToast.success).toHaveBeenCalled(); }); - it("命中稳妥模式模型时不应再展示 Team 横幅提示", async () => { + it("命中稳妥模式模型时不应再展示任务分工横幅提示", async () => { const { container } = renderPanel(); await flushEffects(); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index 2b7aabae6..008968c4a 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -258,8 +258,8 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" onClick={onCopy} - title="复制为自定义 Team" - aria-label="复制为自定义 Team" + title="复制为自定义分工" + aria-label="复制为自定义分工" > @@ -269,8 +269,8 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" onClick={onEdit} - title="编辑 Team" - aria-label="编辑 Team" + title="编辑分工" + aria-label="编辑分工" > @@ -280,8 +280,8 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-rose-50 hover:text-rose-600" onClick={onDelete} - title="删除 Team" - aria-label="删除 Team" + title="删除分工" + aria-label="删除分工" > @@ -412,7 +412,7 @@ export const TeamSelectorPanel: React.FC = ({ }); if (!normalized) { - toast.error("请至少填写 Team 名称和 1 个角色"); + toast.error("请至少填写分工名称和 1 个角色"); return; } @@ -437,12 +437,12 @@ export const TeamSelectorPanel: React.FC = ({ onClose?.(); toast.success( isProjectScopedCustomTeam - ? `已将 Team「${nextTeam.label}」保存到当前项目` - : `已保存 Team「${nextTeam.label}」`, + ? `已将分工「${nextTeam.label}」保存到当前项目` + : `已保存分工「${nextTeam.label}」`, ); } catch (error) { toast.error( - `保存 Team 失败:${error instanceof Error ? error.message : String(error)}`, + `保存分工失败:${error instanceof Error ? error.message : String(error)}`, ); } }; @@ -461,12 +461,12 @@ export const TeamSelectorPanel: React.FC = ({ } toast.success( isProjectScopedCustomTeam - ? `已从当前项目删除 Team「${team.label}」` - : `已删除 Team「${team.label}」`, + ? `已从当前项目删除分工「${team.label}」` + : `已删除分工「${team.label}」`, ); } catch (error) { toast.error( - `删除 Team 失败:${error instanceof Error ? error.message : String(error)}`, + `删除分工失败:${error instanceof Error ? error.message : String(error)}`, ); } }; @@ -523,8 +523,8 @@ export const TeamSelectorPanel: React.FC = ({ if (!inspectorTeam) { return (
- 还没有可预览的 Team。你可以从左侧模板中查看详情,或新建一个自定义 - Team。 + 还没有可预览的分工。你可以从左侧模板中查看详情,或新建一个自定义 + 分工。
); } @@ -561,7 +561,7 @@ export const TeamSelectorPanel: React.FC = ({
{inspectorTeam.description || - "该 Team 将作为主代理拆分子任务时的协作参考。"} + "这套分工会作为主代理拆分子任务时的协作参考。"}
@@ -571,7 +571,7 @@ export const TeamSelectorPanel: React.FC = ({ className="bg-slate-900 text-white hover:bg-slate-800" onClick={() => handleSelect(inspectorTeam)} > - 采用这个 Team + 采用这套分工 ) : null}
) : null} @@ -686,10 +686,10 @@ export const TeamSelectorPanel: React.FC = ({
- {draft.id ? "编辑自定义 Team" : "新建自定义 Team"} + {draft.id ? "编辑自定义分工" : "新建自定义分工"}
- 在右侧完整配置 Team 角色、画像和技能,左侧列表用于浏览与切换模板。 + 在右侧完整配置分工角色、画像和技能,左侧列表用于浏览与切换模板。