From 26751bc117de3d567bc9ae55a327c7a8e62ed62c Mon Sep 17 00:00:00 2001 From: coso Date: Sat, 11 Apr 2026 01:17:15 +0800 Subject: [PATCH] release: v1.7.0 --- RELEASE_NOTES.md | 25 +- docs/aiprompts/commands.md | 20 + docs/aiprompts/playwright-e2e.md | 1 + docs/aiprompts/quality-workflow.md | 7 + docs/aiprompts/workspace.md | 12 + eslint.config.js | 43 +- .../site_adapter_runners.generated.js | 133 +- package-lock.json | 4 +- package.json | 16 +- packages/lime-cli-npm/README.md | 2 +- packages/lime-cli-npm/package.json | 2 +- scripts/browser-runtime-smoke.mjs | 66 +- scripts/check-command-contracts.mjs | 42 +- scripts/generate-agent-runtime-clients.mjs | 276 ++ scripts/verify-gui-smoke.mjs | 8 +- .../.agents/skills/markdown-0fbd21/SKILL.md | 35 + src-tauri/Cargo.lock | 38 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/agent/src/aster_state.rs | 14 + .../crates/agent/src/credential_bridge.rs | 71 +- .../crates/agent/src/request_tool_policy.rs | 245 +- .../crates/aster/src/agents/agent.rs | 5 +- .../crates/aster/src/agents/reply_parts.rs | 17 +- .../aster/src/providers/formats/openai.rs | 139 +- .../crates/aster/src/providers/ollama.rs | 117 +- .../crates/aster/src/providers/toolshim.rs | 114 +- .../aster/src/tools/tool_search_tool.rs | 5 +- src-tauri/crates/core/src/app_paths.rs | 97 + .../core/src/database/dao/api_key_provider.rs | 4 +- .../crates/core/src/database/migration_v6.rs | 370 +++ src-tauri/crates/core/src/database/mod.rs | 1 + .../core/src/database/startup_migrations.rs | 16 +- .../core/src/database/system_providers.rs | 2 +- .../services/src/model_registry_service.rs | 117 +- .../bundled/scripts/x-article-export.js | 133 +- .../bundled/scripts/x-article-export.test.ts | 73 +- src-tauri/src/app/commands/config.rs | 5 +- .../command_api/provider_api.rs | 15 +- src-tauri/src/commands/aster_agent_cmd/dto.rs | 7 + src-tauri/src/commands/aster_agent_cmd/mod.rs | 4 + .../aster_agent_cmd/prompt_context.rs | 43 + .../provider_runtime_strategy.rs | 409 +++ .../request_model_resolution.rs | 176 +- .../commands/aster_agent_cmd/runtime_turn.rs | 120 +- .../aster_agent_cmd/service_skill_launch.rs | 29 + .../src/commands/aster_agent_cmd/tests.rs | 19 +- .../commands/aster_agent_cmd/tool_runtime.rs | 4 +- .../tool_runtime/search_bridge.rs | 2 +- src-tauri/src/dev_bridge/dispatcher.rs | 19 +- src-tauri/src/dev_bridge/dispatcher/models.rs | 55 + src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- .../agent/chat/AgentChatWorkspace.tsx | 244 +- src/components/agent/chat/README.md | 32 +- .../components/AgentThreadTimeline.test.tsx | 32 +- .../chat/components/AgentThreadTimeline.tsx | 35 +- .../components/CanvasSessionOverviewPanel.tsx | 589 ++++ .../components/CanvasWorkbenchLayout.test.tsx | 1241 ++++---- .../chat/components/CanvasWorkbenchLayout.tsx | 2568 ++++++++------- .../ChatModelSelector.integration.test.tsx | 168 +- .../agent/chat/components/EmptyState.test.tsx | 244 +- .../agent/chat/components/EmptyState.tsx | 34 +- .../EmptyStateComposerPanel.test.tsx | 86 +- .../components/EmptyStateComposerPanel.tsx | 222 +- .../chat/components/InlineToolProcessStep.tsx | 36 +- .../components/InputbarComposerSection.tsx | 169 +- .../Inputbar/components/InputbarCore.tsx | 4 +- .../components/InputbarModelExtra.tsx | 8 +- .../chat/components/Inputbar/index.test.tsx | 115 +- .../chat/components/MarkdownRenderer.tsx | 128 +- .../chat/components/MessageList.test.tsx | 198 ++ .../agent/chat/components/MessageList.tsx | 130 +- .../agent/chat/components/README.md | 47 +- .../components/TeamWorkspaceBoard.test.tsx | 12 +- .../chat/components/TeamWorkspaceBoard.tsx | 2756 ++--------------- .../components/TeamWorkspaceDock.test.tsx | 5 +- .../chat/components/ToolCallDisplay.test.tsx | 94 +- .../agent/chat/components/ToolCallDisplay.tsx | 86 +- .../SelectedSessionInlineDetail.tsx | 368 +++ .../TeamWorkspaceBoardHeader.tsx | 144 + .../TeamWorkspaceCanvasLaneCard.tsx | 272 ++ .../TeamWorkspaceCanvasStage.tsx | 168 + .../TeamWorkspaceEmptyShellState.tsx | 91 + .../TeamWorkspaceFormationPanels.tsx | 177 ++ .../TeamWorkspaceTeamOverviewChrome.tsx | 435 +++ .../chat/hooks/agentRuntimeAdapter.test.ts | 88 +- .../agent/chat/hooks/agentRuntimeAdapter.ts | 287 +- .../hooks/agentStreamRuntimeHandler.test.ts | 151 + .../chat/hooks/agentStreamRuntimeHandler.ts | 91 +- .../hooks/agentStreamSubmissionLifecycle.ts | 2 + .../hooks/useAgentRuntimeSyncEffects.test.tsx | 3 + .../chat/hooks/useAsterAgentChat.test.tsx | 79 + .../hooks/useTeamWorkspaceRuntime.test.tsx | 80 +- .../chat/hooks/useTeamWorkspaceRuntime.ts | 1120 +------ src/components/agent/chat/index.test.tsx | 335 +- .../siteCapabilityBinding.test.ts | 73 + .../service-skills/siteCapabilityBinding.ts | 64 +- .../activityPreviewSelectors.test.ts | 156 + .../activityPreviewSelectors.ts | 326 ++ .../boardChromeSelectors.test.ts | 104 + .../boardChromeSelectors.ts | 205 ++ .../canvasLaneSelectors.test.ts | 188 ++ .../canvasLaneSelectors.ts | 570 ++++ .../formationDisplaySelectors.test.ts | 104 + .../formationDisplaySelectors.ts | 235 ++ .../liveRuntimeProjector.test.ts | 124 + .../liveRuntimeProjector.ts | 783 +++++ .../runtimeEventSubscriptions.test.ts | 161 + .../runtimeEventSubscriptions.ts | 313 ++ .../runtimeStateReconciler.test.ts | 58 + .../runtimeStateReconciler.ts | 53 + .../selectedSessionDetailSelectors.test.ts | 112 + .../selectedSessionDetailSelectors.ts | 120 + .../sessionStateSelectors.test.ts | 129 + .../sessionStateSelectors.ts | 87 + .../teamOperationSelectors.test.ts | 143 + .../teamOperationSelectors.ts | 210 ++ .../utils/internalArtifactVisibility.test.ts | 24 +- .../chat/utils/internalArtifactVisibility.ts | 20 + .../latestSavedSiteContentTarget.test.ts | 212 ++ .../utils/latestSavedSiteContentTarget.ts | 92 + .../chat/utils/siteToolResultSummary.test.ts | 70 + .../agent/chat/utils/siteToolResultSummary.ts | 91 +- .../agent/chat/utils/teamWorkspaceCopy.ts | 1 + .../utils/toolSearchResultSummary.test.ts | 100 + .../chat/utils/toolSearchResultSummary.ts | 166 + .../ServiceSkillExecutionCard.test.tsx | 51 +- .../workspace/ServiceSkillExecutionCard.tsx | 34 +- .../agent/chat/workspace/canvasOpenPolicy.ts | 36 + .../canvasWorkbenchDefaultPreview.test.ts | 28 + .../canvasWorkbenchDefaultPreview.ts | 1 + .../workspace/generalCanvasPreviewState.ts | 13 + .../serviceSkillResultFileTarget.test.ts | 118 + .../workspace/serviceSkillResultFileTarget.ts | 121 + .../workspace/serviceSkillSceneLaunch.test.ts | 10 + .../workspace/teamWorkbenchPresentation.tsx | 106 +- ...seWorkspaceArtifactPreviewActions.test.tsx | 29 +- .../useWorkspaceArtifactPreviewActions.ts | 88 +- .../useWorkspaceBrowserAssistRuntime.test.tsx | 4 +- .../useWorkspaceBrowserAssistRuntime.ts | 73 +- .../useWorkspaceCanvasPreviewPresentation.tsx | 45 +- ...eWorkspaceConversationSceneRuntime.test.ts | 150 + .../useWorkspaceConversationSceneRuntime.tsx | 254 +- ...eWorkspaceImageTaskPreviewRuntime.test.tsx | 33 + .../useWorkspaceImageTaskPreviewRuntime.ts | 10 +- ...kspaceImageWorkbenchActionRuntime.test.tsx | 51 +- ...useWorkspaceImageWorkbenchActionRuntime.ts | 6 - .../useWorkspaceImageWorkbenchEventRuntime.ts | 4 +- .../useWorkspaceSendActions.test.tsx | 10 + ...seWorkspaceTeamWorkbenchAutoOpenRuntime.ts | 94 +- .../useWorkspaceWriteFileAction.test.tsx | 4 +- .../workspace/useWorkspaceWriteFileAction.ts | 34 - .../agent/chat/workspace/workbenchPreview.tsx | 1 + .../chat/workspace/workspaceFilePreview.ts | 99 + .../chat/workspace/workspacePath.test.ts | 28 + .../agent/chat/workspace/workspacePath.ts | 69 +- .../workspace/workspaceTreeVisibility.test.ts | 102 + .../chat/workspace/workspaceTreeVisibility.ts | 73 + .../general-chat/canvas/CanvasPanel.test.tsx | 82 + .../general-chat/canvas/CanvasPanel.tsx | 191 +- .../canvas/MarkdownPreview.test.tsx | 100 + .../general-chat/canvas/MarkdownPreview.tsx | 82 +- .../input-kit/ModelSelector.test.tsx | 51 + src/components/input-kit/ModelSelector.tsx | 65 +- .../api-key/AddCustomProviderModal.tsx | 2 +- .../api-key/ApiKeyList.ui.test.tsx | 2 +- .../api-key/ProviderModelList.test.tsx | 2 +- .../settings-v2/_layout/index.test.tsx | 12 +- src/components/settings-v2/_layout/index.tsx | 14 - .../account/profile/index.test.tsx | 6 +- .../settings-v2/account/profile/index.tsx | 233 +- .../settings-v2/account/stats/index.test.tsx | 9 +- .../settings-v2/account/stats/index.tsx | 377 +-- .../user-center-session/index.test.tsx | 11 +- .../account/user-center-session/index.tsx | 85 +- .../agent/media-services/index.test.tsx | 2 + .../agent/media-services/index.tsx | 155 +- .../settings-v2/agent/providers/index.tsx | 92 +- .../general/appearance/index.test.tsx | 9 +- .../settings-v2/general/appearance/index.tsx | 104 +- .../general/hotkeys/index.test.tsx | 7 +- .../settings-v2/general/hotkeys/index.tsx | 78 +- .../settings-v2/general/memory/index.test.tsx | 10 +- .../settings-v2/general/memory/index.tsx | 201 +- .../settings-v2/home/index.test.tsx | 6 +- src/components/settings-v2/home/index.tsx | 204 +- .../settings-v2/hooks/useSettingsCategory.ts | 6 - .../settings-v2/system/about/index.test.tsx | 198 ++ .../settings-v2/system/about/index.tsx | 420 ++- .../AutomationJobDetailsDialog.test.tsx | 189 ++ .../automation/AutomationJobDetailsDialog.tsx | 53 +- .../automation/AutomationJobDialog.test.tsx | 45 + .../system/automation/AutomationJobDialog.tsx | 87 +- .../system/automation/index.test.tsx | 4 +- .../settings-v2/system/automation/index.tsx | 92 +- .../channels/ChannelsDebugWorkbench.tsx | 267 +- .../system/chrome-relay/index.test.tsx | 4 + .../settings-v2/system/chrome-relay/index.tsx | 133 +- .../system/developer/index.test.tsx | 2 + .../settings-v2/system/developer/index.tsx | 146 +- .../system/environment/index.test.tsx | 10 +- .../settings-v2/system/environment/index.tsx | 192 +- .../system/execution-tracker/index.test.tsx | 8 +- .../system/execution-tracker/index.tsx | 188 +- .../system/experimental/index.test.tsx | 11 + .../settings-v2/system/experimental/index.tsx | 146 +- .../system/web-search/index.test.tsx | 9 +- .../settings-v2/system/web-search/index.tsx | 118 +- src/hooks/useCompanionProviderBridge.test.tsx | 18 + src/hooks/useCompanionProviderBridge.ts | 7 + src/hooks/useConfiguredProviders.test.ts | 6 +- src/hooks/useDeepLink.test.tsx | 105 + src/hooks/useDeepLink.ts | 99 +- src/hooks/useOemCloudAccess.ts | 2 +- src/lib/api/agentProtocol.ts | 2 +- src/lib/api/agentRuntime.ts | 1802 +---------- src/lib/api/agentRuntime/agentClient.ts | 75 + .../api/agentRuntime/clientFactory.test.ts | 47 + src/lib/api/agentRuntime/clientFactory.ts | 47 + .../agentRuntime/commandManifest.generated.ts | 384 +++ src/lib/api/agentRuntime/exportClient.ts | 103 + src/lib/api/agentRuntime/index.ts | 72 + src/lib/api/agentRuntime/inventoryClient.ts | 32 + src/lib/api/agentRuntime/mediaClient.ts | 63 + src/lib/api/agentRuntime/normalizers.ts | 373 +++ src/lib/api/agentRuntime/sessionClient.ts | 168 + src/lib/api/agentRuntime/siteClient.ts | 139 + src/lib/api/agentRuntime/subagentClient.ts | 86 + src/lib/api/agentRuntime/threadClient.ts | 131 + src/lib/api/agentRuntime/transport.test.ts | 49 + src/lib/api/agentRuntime/transport.ts | 61 + src/lib/api/agentRuntime/types.ts | 998 ++++++ src/lib/api/agentRuntimeEvents.test.ts | 27 + src/lib/api/agentRuntimeEvents.ts | 102 +- src/lib/api/mediaTasks.ts | 4 +- src/lib/config/providers.ts | 2 +- src/lib/configEventManager.test.ts | 31 + src/lib/configEventManager.ts | 8 + src/lib/dev-bridge/http-client.test.ts | 38 + src/lib/dev-bridge/http-client.ts | 38 +- src/lib/governance/agentCommandCatalog.json | 4 + .../governance/agentRuntimeCommandSchema.json | 239 ++ .../markdown/resolveMarkdownImageSrc.test.ts | 36 + src/lib/markdown/resolveMarkdownImageSrc.ts | 132 + .../model/providerModelFetchSupport.test.ts | 2 +- src/lib/tauri-event.ts | 120 + src/lib/tauri-mock/plugin-deep-link.ts | 9 + src/types/settings.ts | 2 - vite.config.ts | 19 +- 249 files changed, 23042 insertions(+), 10392 deletions(-) create mode 100644 scripts/generate-agent-runtime-clients.mjs create mode 100644 src-tauri/.agents/skills/markdown-0fbd21/SKILL.md create mode 100644 src-tauri/crates/core/src/database/migration_v6.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/provider_runtime_strategy.rs create mode 100644 src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineDetail.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasStage.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceEmptyShellState.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceFormationPanels.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx create mode 100644 src/components/agent/chat/team-workspace-runtime/activityPreviewSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/activityPreviewSelectors.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/runtimeEventSubscriptions.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/runtimeEventSubscriptions.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/runtimeStateReconciler.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/runtimeStateReconciler.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/selectedSessionDetailSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/selectedSessionDetailSelectors.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/sessionStateSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/sessionStateSelectors.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.test.ts create mode 100644 src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.ts create mode 100644 src/components/agent/chat/utils/latestSavedSiteContentTarget.test.ts create mode 100644 src/components/agent/chat/utils/latestSavedSiteContentTarget.ts create mode 100644 src/components/agent/chat/utils/toolSearchResultSummary.test.ts create mode 100644 src/components/agent/chat/utils/toolSearchResultSummary.ts create mode 100644 src/components/agent/chat/workspace/canvasOpenPolicy.ts create mode 100644 src/components/agent/chat/workspace/canvasWorkbenchDefaultPreview.test.ts create mode 100644 src/components/agent/chat/workspace/serviceSkillResultFileTarget.test.ts create mode 100644 src/components/agent/chat/workspace/serviceSkillResultFileTarget.ts create mode 100644 src/components/agent/chat/workspace/workspaceFilePreview.ts create mode 100644 src/components/agent/chat/workspace/workspacePath.test.ts create mode 100644 src/components/agent/chat/workspace/workspaceTreeVisibility.test.ts create mode 100644 src/components/agent/chat/workspace/workspaceTreeVisibility.ts create mode 100644 src/components/general-chat/canvas/CanvasPanel.test.tsx create mode 100644 src/components/general-chat/canvas/MarkdownPreview.test.tsx create mode 100644 src/components/settings-v2/system/about/index.test.tsx create mode 100644 src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx create mode 100644 src/hooks/useDeepLink.test.tsx create mode 100644 src/lib/api/agentRuntime/agentClient.ts create mode 100644 src/lib/api/agentRuntime/clientFactory.test.ts create mode 100644 src/lib/api/agentRuntime/clientFactory.ts create mode 100644 src/lib/api/agentRuntime/commandManifest.generated.ts create mode 100644 src/lib/api/agentRuntime/exportClient.ts create mode 100644 src/lib/api/agentRuntime/index.ts create mode 100644 src/lib/api/agentRuntime/inventoryClient.ts create mode 100644 src/lib/api/agentRuntime/mediaClient.ts create mode 100644 src/lib/api/agentRuntime/normalizers.ts create mode 100644 src/lib/api/agentRuntime/sessionClient.ts create mode 100644 src/lib/api/agentRuntime/siteClient.ts create mode 100644 src/lib/api/agentRuntime/subagentClient.ts create mode 100644 src/lib/api/agentRuntime/threadClient.ts create mode 100644 src/lib/api/agentRuntime/transport.test.ts create mode 100644 src/lib/api/agentRuntime/transport.ts create mode 100644 src/lib/api/agentRuntime/types.ts create mode 100644 src/lib/configEventManager.test.ts create mode 100644 src/lib/governance/agentRuntimeCommandSchema.json create mode 100644 src/lib/markdown/resolveMarkdownImageSrc.test.ts create mode 100644 src/lib/markdown/resolveMarkdownImageSrc.ts create mode 100644 src/lib/tauri-event.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ded3a58b0..488f7e140 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ -## Lime v1.6.1 +## Lime v1.7.0 ### ✨ 主要更新 -- 本次 `v1.6.1` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链 +- 本次 `v1.7.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,26 +10,29 @@ ### 🔗 依赖与版本同步 - `aster-core` / `aster-models` 已内置到 `src-tauri/crates/aster-rust/`,不再依赖外部仓库本地 override -- 应用与 CLI 发布版本提升到 `1.6.1` -- 应用版本入口已对齐到 `1.6.1`,覆盖 `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.6.1` -- `src-tauri/Cargo.lock` 会在本轮 Rust 校验后同步更新到当前 workspace 状态 +- 应用与 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` +- `src-tauri/Cargo.lock` 已在本轮 Rust 校验后同步更新到当前 workspace 状态 ### ⚠️ 发布说明 -- 本次发布 tag 为 `v1.6.1` +- 本次发布 tag 为 `v1.7.0` - 本次发布以当前工作区完整改动为准,不复用旧 tag - 当前 release note 已按这次完整发布内容刷新 -### 🧪 当前校验 +### 🧪 已执行校验 - `npm run verify:app-version` -- Git 提交前的仓库 AI 验证会随提交钩子执行 +- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` +- `cargo test --manifest-path "src-tauri/Cargo.toml"` +- `cargo clippy --manifest-path "src-tauri/Cargo.toml"` +- `npm run lint` ### 📝 文档同步 -- 发布说明已更新为当前这次完整的 `v1.6.1` 内容,可直接作为 GitHub Release note 使用 +- 发布说明已更新为当前这次完整的 `v1.7.0` 内容,可直接作为 GitHub Release note 使用 --- -**完整变更**: `v1.6.0` -> `v1.6.1` +**完整变更**: `v1.6.1` -> `v1.7.0` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 833a93992..b2c00a2bd 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -151,6 +151,7 @@ - `cancel_media_task_artifact` 无论入口来自纯文本命令、slash scene 组合还是显式图片动作,最终都只允许写入当前项目根目录下的标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。若当前来源是文稿 inline 配图,还会继续写入 `usage=document-inline`,并以 `relationships.slot_id` 作为正文占位块与后续任务回填的正式绑定字段;payload 中的 `slot_id` 仅保留兼容读取。若前端已经能推断目标小节,还应继续把 `anchor_section_title` 写入 task payload;若还能识别用户当前选中的具体段落,还应继续把裁剪后的 `anchor_text` 一并写入,用于正文占位图与最终图片的 paragraph 级原位落位。聊天区动态占位、正文占位替换、结果回填、刷新恢复都必须继续以 `.lime/tasks` 为唯一事实源,不允许重新回到前端直连图片服务。 + - `.lime/tasks/**/*.json` 本身是内部任务状态快照,不是面向用户的正式产物。聊天区 artifact 卡片、时间线 file artifact 与默认文件面板都应把这类 JSON 隐藏掉;它们只服务恢复、轮询、取消、重试和诊断,真正给用户看的应该是轻量结果卡、tool timeline 与右侧 viewer。 Workspace `Bash` 运行时在当前主链中应优先解析同名 `lime` 入口:开发态优先回落到 `cargo run -p lime-cli`,打包态优先使用随应用提供的 CLI 二进制。默认 skill 若已经切到 `Bash -> lime media ...`,仍应保留 compat tool 作为兜底,避免在 CLI 暂不可用时把用户流量打断。 @@ -325,6 +326,24 @@ Lime 主应用会在本地维护 `ws://127.0.0.1:45554/companion/pet` 的桌宠 只看其中一侧都不够。只要能力仍然依赖命令边界,就至少要同时核对前端调用、Rust 注册、治理目录册、mock 集合这几面。 +对于 `agent_runtime_*` 这一组运行时主命令,当前还额外有一份结构合同事实源: + +- `src/lib/governance/agentRuntimeCommandSchema.json` + +它负责定义 domain、lifecycle、mock 策略与文档归属,再由 `scripts/generate-agent-runtime-clients.mjs` 生成前端命令 manifest,并在 `npm run test:contracts` 中通过 `--check` 口径校验 schema、治理目录与生成产物没有漂移。 + +当前前端 runtime client 目录也已经固定为: + +- `current`:`src/lib/api/agentRuntime/types.ts`、`src/lib/api/agentRuntime/index.ts` 与各分域 client +- `compat`:`src/lib/api/agentRuntime.ts` + +固定约束: + +- `src/lib/api/agentRuntime/**/*.ts` 内部类型依赖只允许从 `./types` 读取,不要再回绕 `../agentRuntime` +- 外部业务模块继续从 `@/lib/api/agentRuntime` 进入 compat barrel,不要直接跳进分域 client +- `commandManifest.generated.ts` 只由生成器产出,不手工改命令名字符串 +- 新的 `agent_runtime_*` 命令如需落前端主链,优先补 schema / generator / 分域 client,而不是先往 compat 根文件里堆实现 + ## MCP 工具命名主链 MCP bridge 当前唯一继续演进的工具命名事实源是: @@ -495,6 +514,7 @@ npm run verify:local - **用户可见消息工具主链**:继续收敛到 `SendUserMessage`,用于把回复、进度同步、主动提醒和附件送到用户主可见消息面;不要再把这类能力拆到其它平行工具名或旁路协议里 - **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 - **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源 +- **运行时 Provider 能力快照主链**:`agent_runtime_submit_turn.turn_config.provider_config` 允许携带 `model_capabilities / tool_call_strategy / toolshim_model` 这组三个运行时字段;后端会在真正发起 turn 前刷新它们,尤其是 `ollama` 会根据当前模型真实能力在原生 tools 与 `tool_shim` 之间做最终决策。前端不得把模型目录里的静态 tools 标记当作唯一真相 - **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel` - **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据 - **运行时 replay 样本主链**:继续收敛到 `agent_runtime_export_replay_case`,复用 handoff bundle + evidence pack 生成 `input / expected / grader / evidence-links` diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index d21117902..eb1162bd9 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -416,6 +416,7 @@ npm run test:contracts 3. 在两个话题之间来回切换 4. 验证模型选择器恢复的是该话题最近一次 session runtime,而不是陈旧的 localStorage 默认值 5. 如页面暴露运行时摘要条,再确认 provider/model 文案与选择器一致 +6. 如其中一个组合是 `ollama`,再补验证一组原生 tools 模型与一组非原生 tools 模型都能真实发出回复,而不是只拿到模型列表后在运行时卡成 `502 Bad Gateway` ### 话题权限恢复验证 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index b78ee85b7..075826e22 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -226,6 +226,7 @@ npm run bridge:health -- --timeout-ms 120000 - 修改 `create_skill_scaffold_for_app`、技能草稿透传字段,或“聊天结果 -> Skill 脚手架”主链 - 修改 `src/lib/api/document-export.ts`、`save_exported_document`,或把新的 GUI 导出入口接到本地文件保存主链 - 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` +- 修改 `agent_runtime_submit_turn.turn_config.provider_config.model_capabilities / tool_call_strategy / toolshim_model` - 修改 `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` - 修改 `agent_runtime_spawn_subagent` 的 `name / teamName / cwd`、spawn 后 Team 成员写回,或 child `working_dir` / 父子上下文投影语义 - 修改 team runtime tool surface、tool inventory、主线程用户消息工具或协作工具展示,例如 `SendUserMessage`、`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` @@ -507,6 +508,12 @@ npm run bridge:health -- --timeout-ms 120000 - `src/lib/api/agent.test.ts` 一类的网关回归,确认仍走统一 `agent_runtime_*` 主命令 - `HarnessStatusPanel.test.tsx` 一类的 UI 回归,确认导出入口、保存弹窗、状态与制品展示正常 - 受影响 Rust 服务 / 命令的定向测试,确认 `.lime/harness/sessions//...` 一类制品仍能生成 +- 如果这次改动影响 `src/lib/api/agentRuntime/` 的 current 目录结构,例如 `types.ts`、分域 client、`commandManifest.generated.ts` 或 compat 根入口 `agentRuntime.ts`,最低应补: + - `npm run typecheck` + - `npx eslint "src/lib/api/agentRuntime.ts" "src/lib/api/agentRuntime/*.ts" --max-warnings 0` + - `npm test -- src/lib/api/agent.test.ts src/components/agent/chat/hooks/agentRuntimeAdapter.test.ts` + - `npm run test:contracts` + 同时确认目录内实现没有再从 `../agentRuntime` 或 `@/lib/api/agentRuntime` 回绕 compat barrel 取类型。 ## CI 事实源 diff --git a/docs/aiprompts/workspace.md b/docs/aiprompts/workspace.md index cfb50b669..821151ca6 100644 --- a/docs/aiprompts/workspace.md +++ b/docs/aiprompts/workspace.md @@ -23,6 +23,18 @@ Workspace 是 Lime 应用层的概念,用于组织和管理 AI Agent 的工作 2. **最小有效 context** - 只传递必要的 context,避免 context pollution 3. **Workspace = 边界** - 文件系统边界 + context 边界 + 配置边界 +## 文件工作区优先规则 + +对 Lime 的通用工作区,真实文件是第一事实源,`artifact` 只作为增强层存在。 + +固定实施规则: + +1. 用户显式打开项目文件、导出 Markdown、历史任务里的真实文件时,右侧预览必须直接消费真实文件内容,不得先合成 `artifact` 再打开。 +2. `artifact` 继续承担结构化增量写入、工具过程增强、viewer 能力增强,但不能反向定义“当前主稿是什么”。 +3. 当通用工作区正在预览一个具名真实文件时,默认 artifact 自动选中必须让位,避免画布漂移到 `*.artifact.json` 或其他包装稿。 +4. Markdown 预览要以当前文件路径作为基准解析相对图片路径,保证同目录 `images/` 等资源能直接渲染。 +5. 文件标题、默认预览、工作台下载/打开动作应尽量保留项目内相对路径;只有在实际读取磁盘时才解析绝对路径。 + ## 架构设计 ### 层级关系 diff --git a/eslint.config.js b/eslint.config.js index 367856be6..e164e9373 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -471,7 +471,7 @@ const agentRuntimeCommandSelectors = [ ].map((command) => ({ selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, message: - "agent runtime 命令只允许集中放在 `src/lib/api/agentRuntime.ts`;旧 agent_/aster_ 命令已废弃,禁止在业务模块直接扩散。", + "agent runtime 命令只允许集中放在 `src/lib/api/agentRuntime` 网关目录;旧 agent_/aster_ 命令已废弃,禁止在业务模块直接扩散。", })); const projectGatewayCommandSelectors = [ @@ -1158,6 +1158,7 @@ export default [ { files: [ "src/lib/api/agentRuntime.ts", + "src/lib/api/agentRuntime/**/*.ts", "src/lib/api/agentCompat.ts", "src/lib/api/project.ts", "src/lib/api/materials.ts", @@ -1204,4 +1205,44 @@ export default [ "no-restricted-syntax": "off", }, }, + { + files: ["src/lib/api/agentRuntime/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "../agentRuntime", + message: + "agentRuntime 目录内部已收敛到 `./types` + 分域 client;禁止再回绕 compat 根入口 `../agentRuntime`。", + }, + { + name: "@/lib/api/agentRuntime", + message: + "agentRuntime 目录内部已收敛到 `./types` + 分域 client;禁止再通过 alias 根入口回绕 compat barrel。", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/api/*.ts"], + ignores: ["src/lib/api/agentRuntime.ts", "src/lib/api/agent.test.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "./agentRuntime", + message: + "src/lib/api 根层如需复用 agent runtime,请直接依赖 `./agentRuntime/types` 或具体分域 client,不要回绕 compat barrel `./agentRuntime`。", + }, + ], + }, + ], + }, + }, ]; diff --git a/extensions/lime-chrome/site_adapter_runners.generated.js b/extensions/lime-chrome/site_adapter_runners.generated.js index 95af7c7bc..2470c749a 100644 --- a/extensions/lime-chrome/site_adapter_runners.generated.js +++ b/extensions/lime-chrome/site_adapter_runners.generated.js @@ -1293,6 +1293,113 @@ return uniqueCandidates.flatMap((candidate) => serializeCodeBlock(candidate)); } + function extractArticleMarkdown(articleRoot, richTextRoot) { + const state = createState(); + const articleSegments = collectArticleSegments(articleRoot, richTextRoot); + const fallbackCodeBlocks = collectFallbackCodeBlocks(articleRoot); + const potentialMediaCarriers = collectPotentialMediaCarriers(articleRoot); + let markdown = serializeArticleSegments(articleSegments, state); + markdown = appendMissingBlocks(markdown, fallbackCodeBlocks); + markdown = appendMissingBlocks(markdown, collectImageMarkdown(articleRoot, state)); + + return { + state, + fallbackCodeBlocks, + potentialMediaCarriers, + markdown, + }; + } + + function shouldAttemptStructuredContentRecovery(articleRoot, extraction) { + if (!extraction?.markdown) { + return false; + } + + if (extraction.state.images.length > 0) { + return false; + } + + if (extraction.fallbackCodeBlocks.length > 0) { + return false; + } + + if (countExtractedCodeBlocks(extraction.markdown) > 0) { + return false; + } + + const snapshot = buildWarmupSnapshot(articleRoot); + return snapshot.textLength >= 1800 && snapshot.articleBlocks >= 1; + } + + function safeWindowScrollTo(x, y) { + if (typeof window?.scrollTo !== "function") { + return false; + } + + const userAgent = String(globalThis?.navigator?.userAgent || "").toLowerCase(); + if (userAgent.includes("jsdom")) { + return false; + } + + try { + window.scrollTo(x, y); + return true; + } catch { + return false; + } + } + + async function attemptStructuredContentRecovery(articleRoot) { + if (!(articleRoot instanceof Element)) { + return; + } + + const initialScrollY = Number.isFinite(window?.scrollY) + ? Math.round(window.scrollY) + : 0; + if (safeWindowScrollTo(0, 0)) { + await helpers.sleep?.(80); + } + + for (let attempt = 0; attempt < 2; attempt += 1) { + await helpers.waitForDomStable?.({ + root: articleRoot, + stableMs: 650, + timeoutMs: 4200, + }); + await helpers.scrollUntilSettled?.({ + root: articleRoot, + maxScrolls: 18, + delayMs: 420, + settleRounds: 1, + viewportFactor: 0.95, + getSnapshot: () => buildWarmupSnapshot(articleRoot), + }); + await helpers.waitForDomStable?.({ + root: articleRoot, + stableMs: 700, + timeoutMs: 4600, + }); + await helpers.waitForImagesReady?.(articleRoot, { + timeoutMs: 4200, + intervalMs: 250, + stableRounds: 3, + }); + + const snapshot = buildWarmupSnapshot(articleRoot); + if (snapshot.mediaCarriers > 0 || snapshot.codeBlocks > 0) { + break; + } + } + + const currentScrollY = Number.isFinite(window?.scrollY) + ? Math.round(window.scrollY) + : initialScrollY; + if (currentScrollY !== initialScrollY && safeWindowScrollTo(0, initialScrollY)) { + await helpers.sleep?.(80); + } + } + function collectPotentialMediaCarriers(root) { if (!(root instanceof Element)) { return []; @@ -1448,13 +1555,25 @@ }; } - const state = createState(); - const articleSegments = collectArticleSegments(articleRoot, richTextRoot); - const fallbackCodeBlocks = collectFallbackCodeBlocks(articleRoot); - const potentialMediaCarriers = collectPotentialMediaCarriers(articleRoot); - let markdown = serializeArticleSegments(articleSegments, state); - markdown = appendMissingBlocks(markdown, fallbackCodeBlocks); - markdown = appendMissingBlocks(markdown, collectImageMarkdown(articleRoot, state)); + let extraction = extractArticleMarkdown(articleRoot, richTextRoot); + if (shouldAttemptStructuredContentRecovery(articleRoot, extraction)) { + await attemptStructuredContentRecovery(articleRoot); + const recoveredExtraction = extractArticleMarkdown(articleRoot, richTextRoot); + const improved = + recoveredExtraction.state.images.length > extraction.state.images.length || + recoveredExtraction.fallbackCodeBlocks.length > + extraction.fallbackCodeBlocks.length || + countExtractedCodeBlocks(recoveredExtraction.markdown) > + countExtractedCodeBlocks(extraction.markdown); + if (improved) { + extraction = recoveredExtraction; + } + } + + const state = extraction.state; + const fallbackCodeBlocks = extraction.fallbackCodeBlocks; + const potentialMediaCarriers = extraction.potentialMediaCarriers; + let markdown = extraction.markdown; if (!markdown) { return { ok: false, diff --git a/package-lock.json b/package-lock.json index ee6123a3f..f3970b090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.6.1", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.6.1", + "version": "1.7.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 3361491ef..faccbf4fa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.6.1", + "version": "1.7.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -12,18 +12,20 @@ }, "homepage": "https://github.com/aiclientproxy/lime", "scripts": { + "generate:agent-runtime-clients": "node scripts/generate-agent-runtime-clients.mjs", + "check:agent-runtime-clients": "node scripts/generate-agent-runtime-clients.mjs --check", "generate:extension-site-adapters": "node scripts/generate-extension-site-adapter-runners.mjs", "check:extension-site-adapters": "node scripts/generate-extension-site-adapter-runners.mjs --check", "heatmap:project": "node scripts/project-heatmap.mjs", "governance:graph": "node scripts/governance-graph.mjs", - "predev": "npm run generate:extension-site-adapters && npm run verify:app-version && node scripts/ensure-dev-port.mjs", + "predev": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && npm run verify:app-version && node scripts/ensure-dev-port.mjs", "dev": "vite", - "build": "npm run generate:extension-site-adapters && npm run verify:app-version && tsc && vite build", + "build": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && npm run verify:app-version && tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "tauri:dev": "npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev", - "tauri:dev:headless": "npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", - "tauri:dev:nowatch": "npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --no-watch", + "tauri:dev": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev", + "tauri:dev:headless": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", + "tauri:dev:nowatch": "npm run generate:agent-runtime-clients && npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --no-watch", "tauri:dev:profile:trace": "node scripts/run-tauri-profile.mjs trace", "tauri:dev:profile:trace:devtools": "node scripts/run-tauri-profile.mjs trace --open-devtools", "tauri:dev:profile:trace:headless": "node scripts/run-tauri-profile.mjs trace --headless", @@ -39,7 +41,7 @@ "test:watch": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs", "test:frontend": "npm run lint && npm run typecheck && npm test", "test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts", - "test:contracts": "node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs", + "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs", "test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"", "harness:analysis": "node scripts/harness-analysis-brief.mjs", "harness:eval": "node scripts/harness-eval-runner.mjs", diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index f2a78210d..a65c135a8 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.6.1" \ + --version "1.7.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 3696efbea..3fedfb257 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.6.1", + "version": "1.7.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/browser-runtime-smoke.mjs b/scripts/browser-runtime-smoke.mjs index 2013b5669..1610c8047 100644 --- a/scripts/browser-runtime-smoke.mjs +++ b/scripts/browser-runtime-smoke.mjs @@ -13,6 +13,10 @@ const DEFAULTS = { streamMode: "both", }; +const INVOKE_TIMEOUT_MS = 60_000; +const INVOKE_RETRY_COUNT = 3; +const INVOKE_RETRY_DELAY_MS = 500; + function printHelp() { console.log(` Lime Browser Runtime Smoke @@ -111,25 +115,55 @@ function assert(condition, message) { } } -async function invoke(invokeUrl, cmd, args) { - const response = await fetch(invokeUrl, { +function isTransientInvokeError(error) { + return ( + error?.name === "TimeoutError" || + (error instanceof TypeError && error.message === "fetch failed") + ); +} + +async function invoke(options, cmd, args) { + const requestInit = { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ cmd, args }), - }); + signal: AbortSignal.timeout(Math.min(options.timeoutMs, INVOKE_TIMEOUT_MS)), + }; - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) { + try { + const response = await fetch(options.invokeUrl, requestInit); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload?.error) { + throw new Error(String(payload.error)); + } + + return payload?.result; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + if (!isTransientInvokeError(error) || attempt >= INVOKE_RETRY_COUNT) { + if (error?.name === "TimeoutError") { + throw new Error( + `[smoke:browser-runtime] ${cmd} 超时,${Math.min(options.timeoutMs, INVOKE_TIMEOUT_MS)}ms 内未收到 DevBridge 响应`, + ); + } + throw new Error(`[smoke:browser-runtime] ${cmd} 请求失败: ${detail}`); + } + console.warn( + `[smoke:browser-runtime] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${detail}`, + ); + await sleep(INVOKE_RETRY_DELAY_MS); + } } - const payload = await response.json(); - if (payload?.error) { - throw new Error(String(payload.error)); - } - - return payload?.result; + throw new Error(`[smoke:browser-runtime] ${cmd} 请求失败: unknown error`); } async function waitForHealth(options) { @@ -180,7 +214,7 @@ async function main() { let sessionId = null; try { - const launchResponse = await invoke(options.invokeUrl, "launch_browser_session", { + const launchResponse = await invoke(options, "launch_browser_session", { request: { profile_key: profileKey, url: options.launchUrl, @@ -201,7 +235,7 @@ async function main() { ); const sessionState = await invoke( - options.invokeUrl, + options, "get_browser_session_state", { request: { @@ -222,7 +256,7 @@ async function main() { "get_browser_session_state 未返回 target_id", ); - const actionResult = await invoke(options.invokeUrl, "browser_execute_action", { + const actionResult = await invoke(options, "browser_execute_action", { request: { profile_key: profileKey, action: "read_page", @@ -239,7 +273,7 @@ async function main() { "browser_execute_action 未返回对应的 target_id", ); - const auditLogs = await invoke(options.invokeUrl, "get_browser_action_audit_logs", { + const auditLogs = await invoke(options, "get_browser_action_audit_logs", { limit: 10, }); const launchAudit = findLatestAudit( @@ -278,7 +312,7 @@ async function main() { } finally { if (sessionId) { try { - await invoke(options.invokeUrl, "close_cdp_session", { + await invoke(options, "close_cdp_session", { request: { session_id: sessionId, }, diff --git a/scripts/check-command-contracts.mjs b/scripts/check-command-contracts.mjs index 7eda03b49..d3daafe15 100644 --- a/scripts/check-command-contracts.mjs +++ b/scripts/check-command-contracts.mjs @@ -22,6 +22,7 @@ const ignoredDirectories = new Set([ const frontendCommandPatterns = [ /\bsafeInvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g, /\binvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g, + /\binvokeAgentRuntimeBridge(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g, ]; const knownDeferredRegistrationReasons = new Map(); @@ -84,11 +85,19 @@ function addUsage(map, command, relativePath) { map.get(command).add(relativePath); } +function isFrameworkPluginCommand(command) { + return command.startsWith("plugin:"); +} + function extractCommandsFromSource(sourceCode) { const commands = new Set(); for (const pattern of frontendCommandPatterns) { for (const match of sourceCode.matchAll(pattern)) { - commands.add(match[1]); + const command = match[1]; + if (isFrameworkPluginCommand(command)) { + continue; + } + commands.add(command); } } return commands; @@ -109,6 +118,31 @@ function collectFrontendCommandUsage() { return commandUsage; } +function collectAgentRuntimeSchemaUsage() { + const commandUsage = new Map(); + const schemaPath = path.join( + repoRoot, + "src/lib/governance/agentRuntimeCommandSchema.json", + ); + if (!fs.existsSync(schemaPath)) { + return commandUsage; + } + + const relativePath = normalizePath(path.relative(repoRoot, schemaPath)); + const parsed = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + const schemaCommands = Array.isArray(parsed?.commands) ? parsed.commands : []; + + for (const entry of schemaCommands) { + const command = String(entry?.command ?? "").trim(); + if (!command) { + continue; + } + addUsage(commandUsage, command, relativePath); + } + + return commandUsage; +} + function extractBalancedBlock(sourceCode, startIndex, openChar, closeChar) { let depth = 0; let inSingleQuote = false; @@ -309,6 +343,12 @@ function printCommandGroup(title, commands, usageMap) { function main() { const frontendUsage = collectFrontendCommandUsage(); + const agentRuntimeSchemaUsage = collectAgentRuntimeSchemaUsage(); + for (const [command, files] of agentRuntimeSchemaUsage.entries()) { + for (const file of files) { + addUsage(frontendUsage, command, file); + } + } const frontendCommands = new Set(frontendUsage.keys()); const registeredCommands = collectRegisteredCommands(); const mockPriorityCommands = collectMockPriorityCommands(); diff --git a/scripts/generate-agent-runtime-clients.mjs b/scripts/generate-agent-runtime-clients.mjs new file mode 100644 index 000000000..c092e4014 --- /dev/null +++ b/scripts/generate-agent-runtime-clients.mjs @@ -0,0 +1,276 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const schemaPath = path.join( + projectRoot, + "src/lib/governance/agentRuntimeCommandSchema.json", +); +const catalogPath = path.join( + projectRoot, + "src/lib/governance/agentCommandCatalog.json", +); +const outputPath = path.join( + projectRoot, + "src/lib/api/agentRuntime/commandManifest.generated.ts", +); + +const allowedDomains = new Set([ + "thread", + "session", + "export", + "inventory", + "subagent", +]); +const allowedLifecycles = new Set(["current", "compat", "deprecated"]); +const allowedMockStrategies = new Set([ + "default-mock", + "mock-priority", + "bridge-only", +]); + +function toCommandKey(command) { + return command + .replace(/^agent_runtime_/, "") + .split("_") + .map((segment, index) => + index === 0 + ? segment + : segment.charAt(0).toUpperCase() + segment.slice(1), + ) + .join(""); +} + +async function readJson(jsonPath) { + const raw = await fs.readFile(jsonPath, "utf8"); + return JSON.parse(raw); +} + +function validateSchema(schema) { + const commands = Array.isArray(schema?.commands) ? schema.commands : []; + if (commands.length === 0) { + throw new Error("agentRuntimeCommandSchema.json 不能为空"); + } + + const seenCommands = new Set(); + const seenKeys = new Set(); + + for (const entry of commands) { + const command = String(entry?.command ?? "").trim(); + const domain = String(entry?.domain ?? "").trim(); + const requestType = String(entry?.requestType ?? "").trim(); + const responseType = String(entry?.responseType ?? "").trim(); + const lifecycle = String(entry?.lifecycle ?? "").trim(); + const mockStrategy = String(entry?.mockStrategy ?? "").trim(); + const docsSection = String(entry?.docsSection ?? "").trim(); + const key = toCommandKey(command); + + if (!command.startsWith("agent_runtime_")) { + throw new Error(`schema 命令必须以 agent_runtime_ 开头: ${command}`); + } + if (seenCommands.has(command)) { + throw new Error(`schema 命令重复: ${command}`); + } + if (seenKeys.has(key)) { + throw new Error(`schema 命令 key 重复: ${key}`); + } + if (!allowedDomains.has(domain)) { + throw new Error(`schema domain 非法: ${domain}`); + } + if (!requestType) { + throw new Error(`schema 缺少 requestType: ${command}`); + } + if (!responseType) { + throw new Error(`schema 缺少 responseType: ${command}`); + } + if (!allowedLifecycles.has(lifecycle)) { + throw new Error(`schema lifecycle 非法: ${command} -> ${lifecycle}`); + } + if (!allowedMockStrategies.has(mockStrategy)) { + throw new Error( + `schema mockStrategy 非法: ${command} -> ${mockStrategy}`, + ); + } + if (!docsSection) { + throw new Error(`schema 缺少 docsSection: ${command}`); + } + + seenCommands.add(command); + seenKeys.add(key); + } + + return commands.map((entry) => ({ + command: String(entry.command), + key: toCommandKey(String(entry.command)), + domain: String(entry.domain), + requestType: String(entry.requestType), + responseType: String(entry.responseType), + lifecycle: String(entry.lifecycle), + mockStrategy: String(entry.mockStrategy), + docsSection: String(entry.docsSection), + })); +} + +function assertCatalogMatchesSchema(schemaEntries, catalog) { + const schemaCommands = schemaEntries.map((entry) => entry.command); + const catalogCommands = Array.isArray(catalog?.runtimeGatewayCommands) + ? catalog.runtimeGatewayCommands.filter((command) => + String(command).startsWith("agent_runtime_"), + ) + : []; + + const schemaSet = new Set(schemaCommands); + const catalogSet = new Set(catalogCommands); + const missingFromCatalog = schemaCommands.filter( + (command) => !catalogSet.has(command), + ); + const extraInCatalog = catalogCommands.filter( + (command) => !schemaSet.has(command), + ); + + if (missingFromCatalog.length === 0 && extraInCatalog.length === 0) { + return; + } + + const lines = []; + if (missingFromCatalog.length > 0) { + lines.push("catalog 缺少以下 agent_runtime 命令:"); + for (const command of missingFromCatalog) { + lines.push(`- ${command}`); + } + } + if (extraInCatalog.length > 0) { + lines.push("catalog 多出以下未进入 schema 的 agent_runtime 命令:"); + for (const command of extraInCatalog) { + lines.push(`- ${command}`); + } + } + throw new Error(lines.join("\n")); +} + +function buildGeneratedOutput(schemaEntries) { + const domains = [...allowedDomains]; + const lifecycles = [...allowedLifecycles]; + const mockStrategies = [...allowedMockStrategies]; + + const commandObjectBody = schemaEntries + .map((entry) => ` ${entry.key}: ${JSON.stringify(entry.command)},`) + .join("\n"); + + const descriptorBody = schemaEntries + .map( + (entry) => ` { + key: ${JSON.stringify(entry.key)}, + command: AGENT_RUNTIME_COMMANDS.${entry.key}, + domain: ${JSON.stringify(entry.domain)}, + requestType: ${JSON.stringify(entry.requestType)}, + responseType: ${JSON.stringify(entry.responseType)}, + lifecycle: ${JSON.stringify(entry.lifecycle)}, + mockStrategy: ${JSON.stringify(entry.mockStrategy)}, + docsSection: ${JSON.stringify(entry.docsSection)}, + },`, + ) + .join("\n"); + + const namesBody = schemaEntries + .map((entry) => ` AGENT_RUNTIME_COMMANDS.${entry.key},`) + .join("\n"); + + const byDomainBody = domains + .map((domain) => { + const entries = schemaEntries.filter((entry) => entry.domain === domain); + const lines = entries.map( + (entry) => ` AGENT_RUNTIME_COMMANDS.${entry.key},`, + ); + return ` ${JSON.stringify(domain)}: [\n${lines.join("\n")}\n ],`; + }) + .join("\n"); + + return `/** + * 由 scripts/generate-agent-runtime-clients.mjs 自动生成,请勿手改。 + */ + +export const AGENT_RUNTIME_COMMANDS = { +${commandObjectBody} +} as const; + +export type AgentRuntimeCommandKey = keyof typeof AGENT_RUNTIME_COMMANDS; +export type AgentRuntimeCommandName = + (typeof AGENT_RUNTIME_COMMANDS)[AgentRuntimeCommandKey]; +export type AgentRuntimeCommandDomain = ${domains + .map((value) => JSON.stringify(value)) + .join(" | ")}; +export type AgentRuntimeCommandLifecycle = ${lifecycles + .map((value) => JSON.stringify(value)) + .join(" | ")}; +export type AgentRuntimeCommandMockStrategy = ${mockStrategies + .map((value) => JSON.stringify(value)) + .join(" | ")}; + +export interface AgentRuntimeCommandDescriptor { + readonly key: AgentRuntimeCommandKey; + readonly command: AgentRuntimeCommandName; + readonly domain: AgentRuntimeCommandDomain; + readonly requestType: string; + readonly responseType: string; + readonly lifecycle: AgentRuntimeCommandLifecycle; + readonly mockStrategy: AgentRuntimeCommandMockStrategy; + readonly docsSection: string; +} + +export const AGENT_RUNTIME_COMMAND_DESCRIPTORS = [ +${descriptorBody} +] as const satisfies readonly AgentRuntimeCommandDescriptor[]; + +export const AGENT_RUNTIME_COMMAND_NAMES = [ +${namesBody} +] as const satisfies readonly AgentRuntimeCommandName[]; + +export const AGENT_RUNTIME_COMMANDS_BY_DOMAIN = { +${byDomainBody} +} as const satisfies Record< + AgentRuntimeCommandDomain, + readonly AgentRuntimeCommandName[] +>; +`; +} + +async function main() { + const schema = await readJson(schemaPath); + const catalog = await readJson(catalogPath); + const schemaEntries = validateSchema(schema); + assertCatalogMatchesSchema(schemaEntries, catalog); + + const output = buildGeneratedOutput(schemaEntries); + const checkOnly = process.argv.includes("--check"); + + let current = ""; + try { + current = await fs.readFile(outputPath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + if (checkOnly) { + if (current !== output) { + throw new Error( + "agent runtime generated manifest 已过期,请先运行 npm run generate:agent-runtime-clients", + ); + } + return; + } + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, output, "utf8"); +} + +main().catch((error) => { + console.error( + `[generate-agent-runtime-clients] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +}); diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 45b2ebb8e..484d16c1a 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -298,9 +298,13 @@ async function resolveStartupMode(options) { }; } - throw new Error( - `[verify:gui-smoke] 检测到已有前端 dev server 正在占用 ${options.appUrl},但 DevBridge 尚未就绪(${options.healthUrl})。请先关闭现有 dev server,或启动完整 headless 环境后使用 --reuse-running。`, + console.log( + `[verify:gui-smoke] 检测到已有前端 dev server 正在占用 ${options.appUrl},但 DevBridge 尚未就绪;将继续拉起 headless Tauri,并复用现有前端壳。`, ); + return { + shouldStart: true, + reusedExisting: false, + }; } async function main() { diff --git a/src-tauri/.agents/skills/markdown-0fbd21/SKILL.md b/src-tauri/.agents/skills/markdown-0fbd21/SKILL.md new file mode 100644 index 000000000..4bedfd5a4 --- /dev/null +++ b/src-tauri/.agents/skills/markdown-0fbd21/SKILL.md @@ -0,0 +1,35 @@ +--- +name: 我来读取已导出的 Markdown 文件,然后翻... +description: 沉淀自一次成功结果:我来读取已导出的 Markdown 文件,然后翻译成中文。 文件已读取完毕。现在我将正文翻译为中文,保留所有 Markdown 结构、代码块、链... +--- + +# 我来读取已导出的 Markdown 文件,然后翻... + +## 何时使用 +- 当你需要继续产出“我来读取已导出的 Markdown 文件,然后翻...”这类结果时使用。 +- 适合继续沿用这次围绕“我来读取已导出的 Markdown 文件,然后翻译成中文”的结构、判断方式和交付颗粒度。 + +## 输入 +- 目标与主题:我来读取已导出的 Markdown 文件,然后翻译成中文 +- 补充受众、风格、篇幅、平台或输出格式等关键约束。 +- 如有历史版本、参考资料、示例或素材,可一并提供。 + +## 执行步骤 +1. 先确认本次任务是否仍围绕:我来读取已导出的 Markdown 文件,然后翻译成中文 +2. 提炼这次成功结果里的结构骨架,再决定哪些部分需要沿用或改写。 +3. 按相同颗粒度补齐关键信息与执行细节,输出可直接交付的结果。 + +## 输出 +- 交付一份与“我来读取已导出的 Markdown 文件,然后翻...”同类型、可直接使用的完整结果。 +- 输出需要覆盖“我来读取已导出的 Markdown 文件,然后翻译成中文”对应的关键信息,并保持结构清晰。 +- 必要时附带简短说明,便于继续复用或二次迭代。 + +## 失败回退 +- 如果用户目标或素材不足,先补问最关键的主题、受众或平台约束。 +- 如果原结果不适合直接复用,先提炼最小骨架,再给出可继续迭代的首版。 +- 如果信息仍然缺失,明确标注待补内容,不要假设不存在的事实。 + +## 维护提示 +- 如需引用资料,请将文件放到 `references/` 目录。 +- 如需脚本或素材,请分别放到 `scripts/` 与 `assets/` 目录。 +- 如需长期沉淀模板或示例,优先放到相邻目录,不要把所有细节都塞进主文件。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e30f66306..14382f636 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.6.1" +version = "1.7.0" dependencies = [ "anyhow", "arboard", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.6.1" +version = "1.7.0" dependencies = [ "anyhow", "aster-core", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.6.1" +version = "1.7.0" dependencies = [ "chrono", "futures", @@ -5252,7 +5252,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.6.1" +version = "1.7.0" dependencies = [ "clap", "lime-core", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.6.1" +version = "1.7.0" dependencies = [ "async-trait", "lime-core", @@ -5280,7 +5280,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.6.1" +version = "1.7.0" dependencies = [ "aster-models", "async-trait", @@ -5320,7 +5320,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.6.1" +version = "1.7.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.6.1" +version = "1.7.0" dependencies = [ "aes", "axum 0.7.9", @@ -5385,7 +5385,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.6.1" +version = "1.7.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5405,7 +5405,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.6.1" +version = "1.7.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5421,7 +5421,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.6.1" +version = "1.7.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.6.1" +version = "1.7.0" dependencies = [ "async-trait", "lime-core", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.6.1" +version = "1.7.0" dependencies = [ "anyhow", "async-stream", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.6.1" +version = "1.7.0" dependencies = [ "aster-core", "async-stream", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.6.1" +version = "1.7.0" dependencies = [ "axum 0.7.9", "futures", @@ -5586,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.6.1" +version = "1.7.0" dependencies = [ "anyhow", "aster-core", @@ -5628,7 +5628,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.6.1" +version = "1.7.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.6.1" +version = "1.7.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.6.1" +version = "1.7.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9405529d..1506121dc 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.6.1" +version = "1.7.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -189,7 +189,7 @@ version = "2.4" [package] name = "lime" -version = "1.6.1" +version = "1.7.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 0cc9847bb..e7e7aeb4a 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -130,6 +130,10 @@ pub struct ProviderConfig { pub force_responses_api: bool, /// OAuth/本地 Provider 需要的凭证文件路径 pub credential_path: Option, + /// 当前回合是否需要用 toolshim 兼容无原生 tools 的模型 + pub toolshim: bool, + /// toolshim 解释器模型(可与实际回复模型不同) + pub toolshim_model: Option, } impl ProviderContinuationCapable for ProviderConfig { @@ -303,6 +307,8 @@ impl AsterAgentState { .unwrap_or_else(|| format!("manual:{session_id}")), force_responses_api: config.force_responses_api, credential_path: config.credential_path.clone(), + toolshim: config.toolshim, + toolshim_model: config.toolshim_model.clone(), }) .await .map_err(|e| format!("创建 Provider 失败: {e}"))?; @@ -383,6 +389,8 @@ impl AsterAgentState { credential_uuid: Some(aster_config.credential_uuid.clone()), force_responses_api: aster_config.force_responses_api, credential_path: aster_config.credential_path.clone(), + toolshim: aster_config.toolshim, + toolshim_model: aster_config.toolshim_model.clone(), }; let mut config_guard = self.current_provider_config.write().await; *config_guard = Some(config); @@ -846,6 +854,8 @@ mod tests { credential_uuid: None, force_responses_api: false, credential_path: None, + toolshim: false, + toolshim_model: None, }; assert_eq!( @@ -869,6 +879,8 @@ mod tests { credential_uuid: None, force_responses_api: true, credential_path: None, + toolshim: false, + toolshim_model: None, }; assert_eq!( @@ -892,6 +904,8 @@ mod tests { credential_uuid: None, force_responses_api: false, credential_path: None, + toolshim: false, + toolshim_model: None, }; assert_eq!( diff --git a/src-tauri/crates/agent/src/credential_bridge.rs b/src-tauri/crates/agent/src/credential_bridge.rs index 84067f564..58be8922d 100644 --- a/src-tauri/crates/agent/src/credential_bridge.rs +++ b/src-tauri/crates/agent/src/credential_bridge.rs @@ -73,6 +73,10 @@ pub struct AsterProviderConfig { pub force_responses_api: bool, /// OAuth/本地 Provider 需要的凭证文件路径 pub credential_path: Option, + /// 当前回合是否启用 toolshim + pub toolshim: bool, + /// toolshim 解释器模型 + pub toolshim_model: Option, } /// 凭证池桥接器 @@ -297,6 +301,8 @@ impl CredentialBridge { CredentialData::KiroOAuth { creds_file_path } => Some(creds_file_path.clone()), _ => None, }, + toolshim: false, + toolshim_model: None, }) } @@ -438,9 +444,7 @@ pub async fn create_aster_provider( } if config.provider_name == "kiro" { - let model_config = ModelConfig::new(&config.model_name).map_err(|e| { - CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}")) - })?; + let model_config = build_provider_model_config(config)?; let credential_path = config.credential_path.clone().ok_or_else(|| { CredentialBridgeError::ProviderCreationFailed( @@ -465,9 +469,7 @@ pub async fn create_aster_provider( set_provider_env_vars(config); // 创建 ModelConfig - let model_config = ModelConfig::new(&config.model_name).map_err(|e| { - CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}")) - })?; + let model_config = build_provider_model_config(config)?; // 创建 Provider aster::providers::create(&config.provider_name, model_config) @@ -478,6 +480,20 @@ pub async fn create_aster_provider( }) } +fn build_provider_model_config( + config: &AsterProviderConfig, +) -> Result { + ModelConfig::new(&config.model_name) + .map(|model_config| { + model_config + .with_toolshim(config.toolshim) + .with_toolshim_model(config.toolshim_model.clone()) + }) + .map_err(|e| { + CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}")) + }) +} + fn normalize_provider_selector(value: Option<&str>) -> Option { value .map(str::trim) @@ -631,6 +647,15 @@ fn set_provider_env_vars(config: &AsterProviderConfig) { base_url ); } + "ollama" => { + std::env::set_var("OLLAMA_BASE_URL", base_url); + std::env::set_var("OLLAMA_HOST", base_url); + tracing::info!( + "[CredentialBridge] 设置 OLLAMA_BASE_URL={}, OLLAMA_HOST={}", + base_url, + base_url + ); + } _ => { // 其他 Provider 使用通用格式 let base_url_key = format!( @@ -766,6 +791,8 @@ mod tests { credential_uuid: "test-uuid".to_string(), force_responses_api: true, credential_path: None, + toolshim: false, + toolshim_model: None, }; set_provider_env_vars(&config); @@ -798,6 +825,8 @@ mod tests { credential_uuid: "test-uuid".to_string(), force_responses_api: false, credential_path: None, + toolshim: false, + toolshim_model: None, }; assert!(should_disable_provider_default_fast_model(&config)); @@ -814,6 +843,8 @@ mod tests { credential_uuid: "test-uuid".to_string(), force_responses_api: false, credential_path: None, + toolshim: false, + toolshim_model: None, }; assert!(!should_disable_provider_default_fast_model(&config)); @@ -830,6 +861,8 @@ mod tests { credential_uuid: "test-uuid".to_string(), force_responses_api: true, credential_path: None, + toolshim: false, + toolshim_model: None, }; assert!(!should_disable_provider_default_fast_model(&config)); @@ -874,6 +907,8 @@ mod tests { credential_uuid: "test-uuid".to_string(), force_responses_api: false, credential_path: None, + toolshim: false, + toolshim_model: None, }; set_provider_env_vars(&config); @@ -887,4 +922,28 @@ mod tests { Some("https://open.bigmodel.cn/api/anthropic") ); } + + #[test] + fn test_build_provider_model_config_applies_toolshim_override() { + let config = AsterProviderConfig { + provider_name: "openai".to_string(), + provider_selector: Some("ollama".to_string()), + model_name: "deepseek-r1:latest".to_string(), + api_key: None, + base_url: Some("http://127.0.0.1:11434".to_string()), + credential_uuid: "test-uuid".to_string(), + force_responses_api: false, + credential_path: None, + toolshim: true, + toolshim_model: Some("glm-5.1:cloud".to_string()), + }; + + let model_config = build_provider_model_config(&config).expect("build model config"); + + assert!(model_config.toolshim); + assert_eq!( + model_config.toolshim_model.as_deref(), + Some("glm-5.1:cloud") + ); + } } diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 562e37a58..ebeaa9317 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -14,6 +14,8 @@ use futures::{stream, StreamExt}; use lime_core::env_compat; use regex::Regex; use serde::{Deserialize, Serialize}; +use serde_json::Map; +use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::path::Path; use tokio_util::sync::CancellationToken; @@ -286,9 +288,14 @@ struct StreamEventDiagnostics { tool_end_count: usize, error_count: usize, context_trace_events: usize, + artifact_snapshot_count: usize, + persisted_artifact_count: usize, + saved_site_content_count: usize, max_text_delta_chars: usize, max_tool_output_chars: usize, max_context_trace_steps: usize, + last_persisted_artifact_path: Option, + last_saved_markdown_path: Option, } fn update_stream_event_diagnostics( @@ -314,6 +321,13 @@ fn update_stream_event_diagnostics( diagnostics.tool_end_count += 1; let output_chars = result.output.chars().count(); diagnostics.max_tool_output_chars = diagnostics.max_tool_output_chars.max(output_chars); + if tool_result_contains_saved_site_content(result) { + diagnostics.saved_site_content_count += 1; + if diagnostics.last_saved_markdown_path.is_none() { + diagnostics.last_saved_markdown_path = + extract_saved_markdown_path_from_tool_result(result); + } + } if output_chars >= STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS { tracing::warn!( "[AsterAgent][Diag] large tool_end output observed: tool_id={}, output_chars={}, success={}", @@ -334,6 +348,15 @@ fn update_stream_event_diagnostics( ); } } + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { + diagnostics.artifact_snapshot_count += 1; + if artifact_snapshot_is_persisted(artifact) { + diagnostics.persisted_artifact_count += 1; + if diagnostics.last_persisted_artifact_path.is_none() { + diagnostics.last_persisted_artifact_path = Some(artifact.file_path.clone()); + } + } + } RuntimeAgentEvent::Error { .. } => { diagnostics.error_count += 1; } @@ -364,6 +387,103 @@ fn build_empty_final_reply_fallback( Some("本轮执行已结束,过程记录已保留在当前对话中。".to_string()) } +fn read_lookup_string<'a, F>(keys: &[&str], mut lookup: F) -> Option +where + F: FnMut(&str) -> Option<&'a Value>, +{ + keys.iter().find_map(|key| { + lookup(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + }) +} + +fn read_metadata_string(metadata: &HashMap, keys: &[&str]) -> Option { + read_lookup_string(keys, |key| metadata.get(key)) +} + +fn read_json_map_string(metadata: &Map, keys: &[&str]) -> Option { + read_lookup_string(keys, |key| metadata.get(key)) +} + +fn tool_result_contains_saved_site_content(result: &AgentToolResult) -> bool { + let Some(metadata) = result.metadata.as_ref() else { + return false; + }; + metadata + .get("saved_content") + .and_then(Value::as_object) + .is_some() +} + +fn extract_saved_markdown_path_from_tool_result(result: &AgentToolResult) -> Option { + let metadata = result.metadata.as_ref()?; + let saved_content = metadata.get("saved_content")?.as_object()?; + read_json_map_string( + saved_content, + &["markdown_relative_path", "markdownRelativePath"], + ) +} + +fn artifact_snapshot_is_persisted(artifact: &crate::protocol::AgentArtifactSignal) -> bool { + let Some(metadata) = artifact.metadata.as_ref() else { + return false; + }; + + let phase = read_metadata_string(metadata, &["writePhase", "write_phase"]) + .unwrap_or_default() + .to_ascii_lowercase(); + if phase == "persisted" { + return true; + } + + metadata + .get("complete") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn should_downgrade_provider_tail_failure( + error_message: &str, + diagnostics: &StreamEventDiagnostics, + emitted_any: bool, +) -> bool { + emitted_any + && error_message + .trim() + .to_ascii_lowercase() + .starts_with("agent provider execution failed:") + && (diagnostics.saved_site_content_count > 0 || diagnostics.persisted_artifact_count > 0) +} + +fn build_output_preserved_reply_fallback(diagnostics: &StreamEventDiagnostics) -> Option { + if diagnostics.saved_site_content_count > 0 { + let markdown_hint = diagnostics + .last_saved_markdown_path + .as_deref() + .map(|path| format!("(Markdown:{path})")) + .unwrap_or_default(); + return Some(format!( + "本轮站点内容已成功保存到项目文件中{markdown_hint}。由于模型通道暂时不可用,未能补充最终总结;详细过程与产物已保留在当前对话中。" + )); + } + + if diagnostics.persisted_artifact_count > 0 { + let artifact_hint = diagnostics + .last_persisted_artifact_path + .as_deref() + .map(|path| format!("(文件:{path})")) + .unwrap_or_default(); + return Some(format!( + "本轮输出文件已成功生成{artifact_hint}。由于模型通道暂时不可用,未能补充最终总结;详细过程与产物已保留在当前对话中。" + )); + } + + None +} + #[derive(Debug, Default)] struct AutoCompactionProjectionState; @@ -1213,6 +1333,12 @@ where } _ => None, }; + if let Some(message) = inline_provider_error { + return Err(ReplyAttemptError { + message, + emitted_any: true, + }); + } let runtime_events = auto_compaction_projection .project_event(&agent_event) .unwrap_or_else(|| project_runtime_event(agent_event)); @@ -1254,12 +1380,6 @@ where update_stream_event_diagnostics(diagnostics, &runtime_event); on_event(&runtime_event); } - if let Some(message) = inline_provider_error { - return Err(ReplyAttemptError { - message, - emitted_any: true, - }); - } } Err(e) => { return Err(ReplyAttemptError { @@ -1544,7 +1664,7 @@ where let mut text_chunks: Vec = Vec::new(); let mut event_errors: Vec = Vec::new(); let mut diagnostics = StreamEventDiagnostics::default(); - stream_agent_reply_once( + let first_attempt = stream_agent_reply_once( agent, user_message, duplicate_session_config(&session_config), @@ -1558,7 +1678,31 @@ where &mut diagnostics, &mut on_event, ) - .await?; + .await; + if let Err(error) = first_attempt { + if should_downgrade_provider_tail_failure(&error.message, &diagnostics, emitted_any) { + tracing::warn!( + "[AsterAgent][ReplyPolicy] provider tail failure downgraded after persisted output: tools={}, artifacts={}, saved_site={}", + diagnostics.tool_end_count, + diagnostics.persisted_artifact_count, + diagnostics.saved_site_content_count + ); + let fallback_text = text_chunks.join("").trim().to_string(); + return Ok(StreamReplyExecution { + text_output: if fallback_text.is_empty() { + build_output_preserved_reply_fallback(&diagnostics).unwrap_or_else(|| { + "本轮执行已完成,详细过程与产物已保留在当前对话中。".to_string() + }) + } else { + fallback_text + }, + event_errors, + emitted_any, + attempts_summary: web_search_tracker.format_attempts(), + }); + } + return Err(error); + } let current_text_output = text_chunks.join(""); if should_retry_after_empty_reply( @@ -1583,7 +1727,7 @@ where session_config.system_prompt = merge_system_prompt_with_web_search_synthesis_instruction( session_config.system_prompt.take(), ); - stream_agent_reply_once( + let retry_attempt = stream_agent_reply_once( agent, Message::user().with_text(WEB_SEARCH_EMPTY_REPLY_RETRY_PROMPT), duplicate_session_config(&session_config), @@ -1597,7 +1741,27 @@ where &mut diagnostics, &mut on_event, ) - .await?; + .await; + if let Err(error) = retry_attempt { + if should_downgrade_provider_tail_failure(&error.message, &diagnostics, emitted_any) { + tracing::warn!( + "[AsterAgent][ReplyPolicy] provider tail failure downgraded after retry with persisted output: tools={}, artifacts={}, saved_site={}", + diagnostics.tool_end_count, + diagnostics.persisted_artifact_count, + diagnostics.saved_site_content_count + ); + return Ok(StreamReplyExecution { + text_output: build_output_preserved_reply_fallback(&diagnostics) + .unwrap_or_else(|| { + "本轮执行已完成,详细过程与产物已保留在当前对话中。".to_string() + }), + event_errors, + emitted_any, + attempts_summary: web_search_tracker.format_attempts(), + }); + } + return Err(error); + } } if let Err(validation_error) = @@ -1629,9 +1793,7 @@ where emitted_any, }); } - if let Some(fallback_text) = - build_empty_final_reply_fallback(&diagnostics, emitted_any) - { + if let Some(fallback_text) = build_empty_final_reply_fallback(&diagnostics, emitted_any) { tracing::warn!( "[AsterAgent][ReplyPolicy] empty final text downgraded to synthesized fallback: emitted_any={}, tool_starts={}, tool_ends={}, attempts={}", emitted_any, @@ -1885,6 +2047,63 @@ mod tests { assert_eq!(build_empty_final_reply_fallback(&diagnostics, false), None); } + #[test] + fn provider_tail_failure_with_saved_site_content_should_downgrade() { + let diagnostics = StreamEventDiagnostics { + saved_site_content_count: 1, + last_saved_markdown_path: Some("exports/x-article-export/article/index.md".to_string()), + ..Default::default() + }; + + assert!(should_downgrade_provider_tail_failure( + "Agent provider execution failed: Request failed: network timeout", + &diagnostics, + true, + )); + assert_eq!( + build_output_preserved_reply_fallback(&diagnostics).as_deref(), + Some( + "本轮站点内容已成功保存到项目文件中(Markdown:exports/x-article-export/article/index.md)。由于模型通道暂时不可用,未能补充最终总结;详细过程与产物已保留在当前对话中。" + ) + ); + } + + #[test] + fn provider_tail_failure_with_persisted_artifact_should_downgrade() { + let diagnostics = StreamEventDiagnostics { + persisted_artifact_count: 1, + last_persisted_artifact_path: Some("outputs/report.md".to_string()), + ..Default::default() + }; + + assert!(should_downgrade_provider_tail_failure( + "Agent provider execution failed: Request failed: channel unavailable", + &diagnostics, + true, + )); + assert_eq!( + build_output_preserved_reply_fallback(&diagnostics).as_deref(), + Some( + "本轮输出文件已成功生成(文件:outputs/report.md)。由于模型通道暂时不可用,未能补充最终总结;详细过程与产物已保留在当前对话中。" + ) + ); + } + + #[test] + fn provider_tail_failure_without_persisted_output_should_not_downgrade() { + let diagnostics = StreamEventDiagnostics { + tool_end_count: 2, + ..Default::default() + }; + + assert!(!should_downgrade_provider_tail_failure( + "Agent provider execution failed: Request failed: network timeout", + &diagnostics, + true, + )); + assert_eq!(build_output_preserved_reply_fallback(&diagnostics), None); + } + #[test] fn detects_news_expansion_for_daily_news_summary_requests() { assert!(message_suggests_news_expansion("帮我汇总3月13日国际新闻")); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index 50f32e16a..0aa7dabfb 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -4528,7 +4528,10 @@ mod tests { let events = projector.project_agent_event(&AgentEvent::Message(message)); - assert!(events.is_empty(), "agent-only 消息不应再投影到用户可见事件流"); + assert!( + events.is_empty(), + "agent-only 消息不应再投影到用户可见事件流" + ); } #[test] diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs index bf2b5e607..4f1fc0339 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs @@ -127,10 +127,12 @@ fn normalize_response_tool_requests(response: &Message, tool_requests: &[ToolReq async fn toolshim_postprocess( response: Message, toolshim_tools: &[Tool], + toolshim_model: Option<&str>, ) -> Result { - let interpreter = OllamaInterpreter::new().map_err(|e| { - ProviderError::ExecutionError(format!("Failed to create OllamaInterpreter: {}", e)) - })?; + let interpreter = OllamaInterpreter::new_with_model(toolshim_model.map(str::to_string)) + .map_err(|e| { + ProviderError::ExecutionError(format!("Failed to create OllamaInterpreter: {}", e)) + })?; augment_message_with_tool_calls(&interpreter, response, toolshim_tools) .await @@ -283,7 +285,14 @@ impl Agent { // Post-process / structure the response only if tool interpretation is enabled if message.is_some() && model_config.toolshim { - message = Some(toolshim_postprocess(message.unwrap(), &toolshim_tools).await?); + message = Some( + toolshim_postprocess( + message.unwrap(), + &toolshim_tools, + model_config.toolshim_model.as_deref(), + ) + .await?, + ); } yield (message, usage); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs index fd4418c2f..1771201cb 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs @@ -38,8 +38,9 @@ struct Delta { content: Option, role: Option, tool_calls: Option>, - /// DeepSeek reasoner 模型的推理内容 + /// OpenAI-compatible 推理内容,兼容 DeepSeek(`reasoning_content`) 与 Ollama(`reasoning`) reasoning_content: Option, + reasoning: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -306,10 +307,8 @@ pub fn response_to_message(response: &Value) -> anyhow::Result { let mut content = Vec::new(); - if let Some(reasoning) = original.get("reasoning_content").and_then(|v| v.as_str()) { - if !reasoning.is_empty() { - content.push(MessageContent::thinking(reasoning, "")); - } + if let Some(reasoning) = extract_openai_reasoning_text(original) { + content.push(MessageContent::thinking(reasoning, "")); } if let Some(text) = original.get("content") { @@ -458,6 +457,29 @@ fn strip_data_prefix(line: &str) -> Option<&str> { line.strip_prefix("data: ").map(|s| s.trim()) } +fn normalized_openai_reasoning_text<'a>( + reasoning_content: Option<&'a str>, + reasoning: Option<&'a str>, +) -> Option<&'a str> { + reasoning_content + .filter(|value| !value.is_empty()) + .or_else(|| reasoning.filter(|value| !value.is_empty())) +} + +fn extract_openai_reasoning_text(message: &Value) -> Option<&str> { + normalized_openai_reasoning_text( + message.get("reasoning_content").and_then(Value::as_str), + message.get("reasoning").and_then(Value::as_str), + ) +} + +fn extract_openai_stream_reasoning_delta(delta: &Delta) -> Option<&str> { + normalized_openai_reasoning_text( + delta.reasoning_content.as_deref(), + delta.reasoning.as_deref(), + ) +} + pub fn response_to_streaming_message( mut stream: S, ) -> impl Stream, Option)>> + 'static @@ -579,11 +601,11 @@ where } } - // 如果有 reasoning_content(DeepSeek reasoner),添加为 Thinking 内容 - if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content { - if !reasoning.is_empty() { - contents.insert(0, MessageContent::thinking(reasoning.clone(), "")); - } + // 如果有推理内容,添加为 Thinking 内容 + if let Some(reasoning) = + extract_openai_stream_reasoning_delta(&chunk.choices[0].delta) + { + contents.insert(0, MessageContent::thinking(reasoning.to_string(), "")); } let mut msg = Message::new( @@ -601,14 +623,16 @@ where Some(msg), usage, ) - } else if chunk.choices[0].delta.content.is_some() || chunk.choices[0].delta.reasoning_content.is_some() { + } else if chunk.choices[0].delta.content.is_some() + || extract_openai_stream_reasoning_delta(&chunk.choices[0].delta).is_some() + { let mut contents = Vec::new(); - // 处理 reasoning_content(DeepSeek reasoner) - if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content { - if !reasoning.is_empty() { - contents.push(MessageContent::thinking(reasoning.clone(), "")); - } + // 处理推理内容(兼容 reasoning_content / reasoning) + if let Some(reasoning) = + extract_openai_stream_reasoning_delta(&chunk.choices[0].delta) + { + contents.push(MessageContent::thinking(reasoning.to_string(), "")); } // 处理普通文本内容 @@ -1171,6 +1195,36 @@ mod tests { Ok(()) } + #[test] + fn test_response_to_message_with_ollama_reasoning_alias() -> anyhow::Result<()> { + let response = json!({ + "choices": [{ + "role": "assistant", + "message": { + "reasoning": "先思考,再给答案。", + "content": "收到" + } + }] + }); + + let message = response_to_message(&response)?; + assert_eq!(message.content.len(), 2); + + if let MessageContent::Thinking(thinking) = &message.content[0] { + assert_eq!(thinking.thinking, "先思考,再给答案。"); + } else { + panic!("Expected Thinking content"); + } + + if let MessageContent::Text(text) = &message.content[1] { + assert_eq!(text.text, "收到"); + } else { + panic!("Expected Text content"); + } + + Ok(()) + } + #[test] fn test_response_to_message_valid_toolrequest() -> anyhow::Result<()> { let response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; @@ -1558,4 +1612,57 @@ data: [DONE] panic!("Expected tool call message with two calls, but did not see it"); } + + #[tokio::test] + async fn test_streaming_response_with_ollama_reasoning_delta() -> anyhow::Result<()> { + let response_lines = [ + r#"data: {"model":"deepseek-r1:latest","choices":[{"delta":{"role":"assistant","reasoning":"先想一下"},"index":0,"finish_reason":null}],"id":"chatcmpl-ollama-1"}"#, + r#"data: {"model":"deepseek-r1:latest","choices":[{"delta":{"content":"收到"},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10},"id":"chatcmpl-ollama-1"}"#, + "data: [DONE]", + ]; + + let response_stream = + tokio_stream::iter(response_lines.into_iter().map(|line| Ok(line.to_string()))); + let messages = response_to_streaming_message(response_stream); + pin!(messages); + + let mut seen_thinking = false; + let mut seen_text = false; + let mut seen_usage = false; + + while let Some(Ok((message, usage))) = messages.next().await { + if let Some(msg) = message { + for content in msg.content { + match content { + MessageContent::Thinking(thinking) => { + assert_eq!(thinking.thinking, "先想一下"); + seen_thinking = true; + } + MessageContent::Text(text) => { + assert_eq!(text.text, "收到"); + seen_text = true; + } + other => panic!("Unexpected content: {:?}", other), + } + } + } + + if let Some(provider_usage) = usage { + assert_eq!(provider_usage.model, "deepseek-r1:latest"); + assert_eq!(provider_usage.usage.input_tokens, Some(7)); + assert_eq!(provider_usage.usage.output_tokens, Some(3)); + assert_eq!(provider_usage.usage.total_tokens, Some(10)); + seen_usage = true; + } + } + + assert!( + seen_thinking, + "Expected reasoning delta to emit Thinking content" + ); + assert!(seen_text, "Expected content delta to emit Text content"); + assert!(seen_usage, "Expected finish chunk to emit usage"); + + Ok(()) + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/ollama.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/ollama.rs index cb40c5874..cf3f62a80 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/ollama.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/ollama.rs @@ -22,7 +22,8 @@ use serde_json::Value; use std::time::Duration; use url::Url; -pub const OLLAMA_HOST: &str = "localhost"; +pub const OLLAMA_HOST: &str = "127.0.0.1"; +pub const OLLAMA_BASE_URL_ENV: &str = "OLLAMA_BASE_URL"; pub const OLLAMA_TIMEOUT: u64 = 600; pub const OLLAMA_DEFAULT_PORT: u16 = 11434; pub const OLLAMA_DEFAULT_MODEL: &str = "qwen3"; @@ -43,36 +44,61 @@ pub struct OllamaProvider { } impl OllamaProvider { - pub async fn from_env(model: ModelConfig) -> Result { - let config = crate::config::Config::global(); - let host: String = config - .get_param("OLLAMA_HOST") - .unwrap_or_else(|_| OLLAMA_HOST.to_string()); + pub(crate) fn resolve_base_url( + base_url_override: Option<&str>, + host_override: Option<&str>, + ) -> Result { + let candidate = base_url_override + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + host_override + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .unwrap_or(OLLAMA_HOST); - let timeout: Duration = - Duration::from_secs(config.get_param("OLLAMA_TIMEOUT").unwrap_or(OLLAMA_TIMEOUT)); - - let base = if host.starts_with("http://") || host.starts_with("https://") { - host.clone() + let base = if candidate.starts_with("http://") || candidate.starts_with("https://") { + candidate.to_string() } else { - format!("http://{}", host) + format!("http://{candidate}") }; let mut base_url = Url::parse(&base).map_err(|e| anyhow::anyhow!("Invalid base URL: {e}"))?; - let explicit_port = host.contains(':'); - let is_localhost = host == "localhost" || host == "127.0.0.1" || host == "::1"; + if matches!(base_url.host_str(), Some("localhost")) { + base_url + .set_host(Some(OLLAMA_HOST)) + .map_err(|_| anyhow::anyhow!("Failed to normalize localhost to IPv4 loopback"))?; + } - if base_url.port().is_none() && !explicit_port && !host.starts_with("http") && is_localhost - { + let explicit_default_port = candidate.ends_with(":80") || candidate.ends_with(":443"); + let is_https = base_url.scheme() == "https"; + + if base_url.port().is_none() && !explicit_default_port && !is_https { base_url .set_port(Some(OLLAMA_DEFAULT_PORT)) .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } + Ok(base_url) + } + + pub async fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let host = config.get_param::("OLLAMA_HOST").ok(); + let base_url = config.get_param::(OLLAMA_BASE_URL_ENV).ok(); + + let timeout: Duration = + Duration::from_secs(config.get_param("OLLAMA_TIMEOUT").unwrap_or(OLLAMA_TIMEOUT)); + let auth = AuthMethod::Custom(Box::new(NoAuth)); - let api_client = ApiClient::with_timeout(base_url.to_string(), auth, timeout)?; + let api_client = ApiClient::with_timeout( + Self::resolve_base_url(base_url.as_deref(), host.as_deref())?.to_string(), + auth, + timeout, + )?; Ok(Self { api_client, @@ -88,28 +114,12 @@ impl OllamaProvider { ) -> Result { let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); - let base = - if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") { - config.base_url.clone() - } else { - format!("http://{}", config.base_url) - }; - - let mut base_url = Url::parse(&base) - .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; - - let explicit_default_port = - config.base_url.ends_with(":80") || config.base_url.ends_with(":443"); - let is_https = base_url.scheme() == "https"; - - if base_url.port().is_none() && !explicit_default_port && !is_https { - base_url - .set_port(Some(OLLAMA_DEFAULT_PORT)) - .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; - } - let auth = AuthMethod::Custom(Box::new(NoAuth)); - let api_client = ApiClient::with_timeout(base_url.to_string(), auth, timeout)?; + let api_client = ApiClient::with_timeout( + Self::resolve_base_url(Some(&config.base_url), None)?.to_string(), + auth, + timeout, + )?; Ok(Self { api_client, @@ -128,6 +138,37 @@ impl OllamaProvider { } } +#[cfg(test)] +mod tests { + use super::OllamaProvider; + + #[test] + fn should_normalize_localhost_to_ipv4_loopback() { + let url = OllamaProvider::resolve_base_url(Some("http://localhost:11434"), None) + .expect("resolve localhost base url"); + + assert_eq!(url.as_str(), "http://127.0.0.1:11434/"); + } + + #[test] + fn should_apply_default_port_for_host_only_input() { + let url = OllamaProvider::resolve_base_url(None, Some("127.0.0.1")).expect("resolve host"); + + assert_eq!(url.as_str(), "http://127.0.0.1:11434/"); + } + + #[test] + fn should_prefer_base_url_override_over_host_override() { + let url = OllamaProvider::resolve_base_url( + Some("http://127.0.0.1:11434/api"), + Some("127.0.0.1:9999"), + ) + .expect("resolve base url override"); + + assert_eq!(url.as_str(), "http://127.0.0.1:11434/api"); + } +} + struct NoAuth; #[async_trait] 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 c3fd1a2cd..63cd63869 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 @@ -31,7 +31,8 @@ //! use super::errors::ProviderError; -use super::ollama::OLLAMA_DEFAULT_PORT; +use super::ollama::OllamaProvider; +use super::ollama::OLLAMA_BASE_URL_ENV; use super::ollama::OLLAMA_HOST; use crate::conversation::message::{Message, MessageContent}; use crate::conversation::Conversation; @@ -66,10 +67,15 @@ pub trait ToolInterpreter { pub struct OllamaInterpreter { client: Client, base_url: String, + interpreter_model: Option, } impl OllamaInterpreter { pub fn new() -> Result { + Self::new_with_model(None) + } + + pub fn new_with_model(interpreter_model: Option) -> Result { let client = Client::builder() .timeout(Duration::from_secs(600)) .build() @@ -77,40 +83,27 @@ impl OllamaInterpreter { let base_url = Self::get_ollama_base_url()?; - Ok(Self { client, base_url }) + Ok(Self { + client, + base_url, + interpreter_model: normalize_interpreter_model_override(interpreter_model), + }) } /// Get the Ollama base URL from existing config or use default values fn get_ollama_base_url() -> Result { let config = crate::config::Config::global(); - let host: String = config - .get_param("OLLAMA_HOST") - .unwrap_or_else(|_| OLLAMA_HOST.to_string()); + let host = config + .get_param::("OLLAMA_HOST") + .ok() + .unwrap_or_else(|| OLLAMA_HOST.to_string()); + let base_url = config.get_param::(OLLAMA_BASE_URL_ENV).ok(); - // Format the URL correctly with http:// prefix if needed - let base = if host.starts_with("http://") || host.starts_with("https://") { - &host - } else { - &format!("http://{}", host) - }; - - let mut base_url = url::Url::parse(base) - .map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?; - - // Set the default port if missing - // Don't add default port if: - // 1. URL explicitly ends with standard ports (:80 or :443) - // 2. URL uses HTTPS (which implicitly uses port 443) - let explicit_default_port = host.ends_with(":80") || host.ends_with(":443"); - let is_https = base_url.scheme() == "https"; - - if base_url.port().is_none() && !explicit_default_port && !is_https { - base_url.set_port(Some(OLLAMA_DEFAULT_PORT)).map_err(|_| { - ProviderError::RequestFailed("Failed to set default port".to_string()) - })?; - } - - Ok(base_url.to_string()) + Ok( + OllamaProvider::resolve_base_url(base_url.as_deref(), Some(&host)) + .map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))? + .to_string(), + ) } fn tool_structured_ouput_format_schema() -> Value { @@ -240,6 +233,26 @@ impl OllamaInterpreter { } } +fn normalize_interpreter_model_override(interpreter_model: Option) -> Option { + interpreter_model + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn resolve_interpreter_model(explicit_model: Option<&str>) -> String { + explicit_model + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + std::env::var("ASTER_TOOLSHIM_OLLAMA_MODEL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + .unwrap_or_else(|| DEFAULT_INTERPRETER_MODEL_OLLAMA.to_string()) +} + #[async_trait::async_trait] impl ToolInterpreter for OllamaInterpreter { async fn interpret_to_tool_calls( @@ -283,8 +296,7 @@ Otherwise, if no JSON tool requests are provided, use the no-op tool: let format_schema = OllamaInterpreter::tool_structured_ouput_format_schema(); // Determine which model to use for interpretation (from env var or default) - let interpreter_model = std::env::var("ASTER_TOOLSHIM_OLLAMA_MODEL") - .unwrap_or_else(|_| DEFAULT_INTERPRETER_MODEL_OLLAMA.to_string()); + let interpreter_model = resolve_interpreter_model(self.interpreter_model.as_deref()); // Make a call to ollama with structured output let interpreter_response = self @@ -443,3 +455,43 @@ pub async fn augment_message_with_tool_calls( Ok(final_message) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static TOOLSHIM_ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn resolve_interpreter_model_prefers_explicit_override() { + let _guard = TOOLSHIM_ENV_LOCK.lock().expect("lock env"); + std::env::set_var("ASTER_TOOLSHIM_OLLAMA_MODEL", "env-model"); + + let resolved = resolve_interpreter_model(Some("glm-5.1:cloud")); + + assert_eq!(resolved, "glm-5.1:cloud"); + std::env::remove_var("ASTER_TOOLSHIM_OLLAMA_MODEL"); + } + + #[test] + fn resolve_interpreter_model_falls_back_to_env() { + let _guard = TOOLSHIM_ENV_LOCK.lock().expect("lock env"); + std::env::set_var("ASTER_TOOLSHIM_OLLAMA_MODEL", "env-model"); + + let resolved = resolve_interpreter_model(None); + + assert_eq!(resolved, "env-model"); + std::env::remove_var("ASTER_TOOLSHIM_OLLAMA_MODEL"); + } + + #[test] + fn resolve_interpreter_model_uses_default_when_no_override_exists() { + let _guard = TOOLSHIM_ENV_LOCK.lock().expect("lock env"); + std::env::remove_var("ASTER_TOOLSHIM_OLLAMA_MODEL"); + + let resolved = resolve_interpreter_model(None); + + assert_eq!(resolved, DEFAULT_INTERPRETER_MODEL_OLLAMA); + } +} diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs index 4291247c4..d036cc74b 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/tool_search_tool.rs @@ -1040,7 +1040,10 @@ mod tests { let matches = score_query_match( "final output tool", &[], - &[searchable("StructuredOutput", "return the final JSON answer")], + &[searchable( + "StructuredOutput", + "return the final JSON answer", + )], ); assert_eq!(matches, vec!["StructuredOutput".to_string()]); diff --git a/src-tauri/crates/core/src/app_paths.rs b/src-tauri/crates/core/src/app_paths.rs index 7ad047030..bd846fa84 100644 --- a/src-tauri/crates/core/src/app_paths.rs +++ b/src-tauri/crates/core/src/app_paths.rs @@ -138,6 +138,16 @@ pub fn resolve_default_project_dir() -> Result { with_app_roots(resolve_default_project_dir_from_source_roots) } +pub fn migrate_managed_project_path_to_preferred(path: &Path) -> Result, String> { + with_app_roots(|preferred_root, legacy_roots| { + migrate_managed_project_path_to_preferred_from_source_roots( + path, + preferred_root, + legacy_roots, + ) + }) +} + pub fn best_effort_runtime_subdir(subdir: &str) -> PathBuf { resolve_runtime_subdir(subdir).unwrap_or_else(|_| fallback_runtime_subdir(subdir)) } @@ -440,6 +450,52 @@ fn resolve_subdir_with_legacy_copy_from_roots( ) } +fn migrate_managed_project_path_to_preferred_from_source_roots( + path: &Path, + preferred_root: &Path, + legacy_roots: &[PathBuf], +) -> Result, String> { + let preferred_projects_root = preferred_root.join("projects"); + fs::create_dir_all(&preferred_projects_root).map_err(|e| { + format!( + "无法创建当前项目目录 {}: {e}", + preferred_projects_root.display() + ) + })?; + + if let Ok(relative_path) = path.strip_prefix(&preferred_projects_root) { + return Ok(Some(preferred_projects_root.join(relative_path))); + } + + for legacy_root in legacy_roots { + let legacy_projects_root = legacy_root.join("projects"); + let Ok(relative_path) = path.strip_prefix(&legacy_projects_root) else { + continue; + }; + + let target_path = preferred_projects_root.join(relative_path); + fs::create_dir_all(&target_path) + .map_err(|e| format!("无法创建项目目录 {}: {e}", target_path.display()))?; + + if path.exists() && path.is_dir() { + copy_dir_contents_if_missing(path, &target_path)?; + } + + return Ok(Some(target_path)); + } + + Ok(None) +} + +#[cfg(test)] +pub(crate) fn migrate_managed_project_path_to_preferred_from_roots( + path: &Path, + preferred_root: &Path, + legacy_roots: &[PathBuf], +) -> Result, String> { + migrate_managed_project_path_to_preferred_from_source_roots(path, preferred_root, legacy_roots) +} + fn dir_has_entries(path: &Path) -> bool { fs::read_dir(path) .ok() @@ -887,6 +943,47 @@ mod tests { assert!(resolved.is_dir()); } + #[test] + fn migrate_managed_project_path_to_preferred_remaps_proxycast_home_project() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("lime"); + let legacy_root = temp.path().join("home").join(".proxycast"); + let legacy_project = legacy_root.join("projects").join("default"); + fs::create_dir_all(&legacy_project).unwrap(); + fs::write(legacy_project.join("index.md"), "# legacy").unwrap(); + + let resolved = migrate_managed_project_path_to_preferred_from_roots( + &legacy_project, + &preferred_root, + &[legacy_root], + ) + .unwrap(); + + let expected = preferred_root.join("projects").join("default"); + assert_eq!(resolved, Some(expected.clone())); + assert_eq!( + fs::read_to_string(expected.join("index.md")).unwrap(), + "# legacy" + ); + } + + #[test] + fn migrate_managed_project_path_to_preferred_ignores_custom_project() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("lime"); + let legacy_root = temp.path().join("home").join(".proxycast"); + let custom_project = temp.path().join("workspace").join("demo"); + + let resolved = migrate_managed_project_path_to_preferred_from_roots( + &custom_project, + &preferred_root, + &[legacy_root], + ) + .unwrap(); + + assert_eq!(resolved, None); + } + #[test] fn fallback_runtime_subdir_uses_lime_temp_namespace() { let fallback = fallback_runtime_subdir("logs"); diff --git a/src-tauri/crates/core/src/database/dao/api_key_provider.rs b/src-tauri/crates/core/src/database/dao/api_key_provider.rs index c3a768340..44d972c4b 100644 --- a/src-tauri/crates/core/src/database/dao/api_key_provider.rs +++ b/src-tauri/crates/core/src/database/dao/api_key_provider.rs @@ -109,7 +109,7 @@ impl ApiProviderType { }, ApiProviderType::Ollama => ProviderRuntimeSpec { protocol_family: ProviderProtocolFamily::Ollama, - default_api_host: "http://localhost:11434", + default_api_host: "http://127.0.0.1:11434", auth_header: "Authorization", auth_prefix: Some("Bearer"), extra_headers: &NO_EXTRA_HEADERS, @@ -278,7 +278,7 @@ mod tests { ( ApiProviderType::Ollama, ProviderProtocolFamily::Ollama, - "http://localhost:11434", + "http://127.0.0.1:11434", "Authorization", Some("Bearer"), "ollama", diff --git a/src-tauri/crates/core/src/database/migration_v6.rs b/src-tauri/crates/core/src/database/migration_v6.rs new file mode 100644 index 000000000..a2e4057c7 --- /dev/null +++ b/src-tauri/crates/core/src/database/migration_v6.rs @@ -0,0 +1,370 @@ +//! 受管项目路径迁移 +//! +//! 统一把历史 `.proxycast/projects/...`、`.lime/projects/...` 与旧 appdata +//! 下的受管项目目录迁移到当前 `lime/projects/...` 主路径,避免数据库和 +//! 会话继续暴露旧品牌目录。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use rusqlite::{params, Connection}; + +use crate::app_paths; + +use super::migration_support::{ + is_migration_completed, mark_migration_completed, run_in_transaction, +}; + +const MIGRATION_KEY_MANAGED_WORKSPACE_PATHS_TO_LIME: &str = + "migrated_managed_workspace_paths_to_lime_v1"; + +pub struct MigrationResult { + pub executed: bool, + pub migrated_workspaces: usize, + pub migrated_sessions: usize, + pub skipped_workspaces: usize, +} + +pub fn migrate_managed_workspace_paths(conn: &Connection) -> Result { + migrate_managed_workspace_paths_with_mapper( + conn, + &app_paths::migrate_managed_project_path_to_preferred, + ) +} + +fn migrate_managed_workspace_paths_with_mapper( + conn: &Connection, + remap_path: &F, +) -> Result +where + F: Fn(&Path) -> Result, String>, +{ + if is_migration_completed(conn, MIGRATION_KEY_MANAGED_WORKSPACE_PATHS_TO_LIME) { + tracing::debug!("[迁移] 受管项目 legacy 路径已迁移到 lime,跳过"); + return Ok(MigrationResult { + executed: false, + migrated_workspaces: 0, + migrated_sessions: 0, + skipped_workspaces: 0, + }); + } + + match run_in_transaction(conn, |tx| { + let (migrated_workspaces, migrated_sessions, skipped_workspaces) = + execute_migration(tx, remap_path)?; + mark_migration_completed(tx, MIGRATION_KEY_MANAGED_WORKSPACE_PATHS_TO_LIME)?; + Ok((migrated_workspaces, migrated_sessions, skipped_workspaces)) + }) { + Ok((migrated_workspaces, migrated_sessions, skipped_workspaces)) => Ok(MigrationResult { + executed: migrated_workspaces > 0 || migrated_sessions > 0, + migrated_workspaces, + migrated_sessions, + skipped_workspaces, + }), + Err(error) => { + tracing::error!("[迁移] 受管项目 legacy 路径迁移失败,已回滚: {}", error); + Err(error) + } + } +} + +fn execute_migration(conn: &Connection, remap_path: &F) -> Result<(usize, usize, usize), String> +where + F: Fn(&Path) -> Result, String>, +{ + let mut path_cache: HashMap> = HashMap::new(); + let mut workspace_updates: Vec<(String, String, String)> = Vec::new(); + let mut session_updates: Vec<(String, String)> = Vec::new(); + let mut skipped_workspaces = 0usize; + + let mut resolve_target_path = |original: &str| -> Result, String> { + if let Some(cached) = path_cache.get(original) { + return Ok(cached.clone()); + } + + let mapped = + remap_path(Path::new(original))?.map(|path| path.to_string_lossy().to_string()); + path_cache.insert(original.to_string(), mapped.clone()); + Ok(mapped) + }; + + { + let mut stmt = conn + .prepare("SELECT id, root_path FROM workspaces") + .map_err(|e| format!("查询 workspaces 失败: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("读取 workspaces 失败: {e}"))?; + + for row in rows { + let (workspace_id, root_path) = + row.map_err(|e| format!("解析 workspace 行失败: {e}"))?; + let Some(target_path) = resolve_target_path(&root_path)? else { + continue; + }; + if target_path == root_path { + continue; + } + + if let Some(existing_workspace_id) = find_workspace_id_by_root_path(conn, &target_path)? + { + if existing_workspace_id != workspace_id { + skipped_workspaces += 1; + tracing::warn!( + "[迁移] 跳过 workspace 路径迁移,目标路径已被占用: workspace_id={}, root_path={}, target_path={}, existing_workspace_id={}", + workspace_id, + root_path, + target_path, + existing_workspace_id + ); + continue; + } + } + + workspace_updates.push((workspace_id, root_path, target_path)); + } + } + + for (workspace_id, original_path, target_path) in &workspace_updates { + conn.execute( + "UPDATE workspaces SET root_path = ?1 WHERE id = ?2", + params![target_path, workspace_id], + ) + .map_err(|e| { + format!( + "更新 workspace 路径失败: workspace_id={}, root_path={} -> {}: {e}", + workspace_id, original_path, target_path + ) + })?; + } + + { + let mut stmt = conn + .prepare( + "SELECT id, working_dir + FROM agent_sessions + WHERE working_dir IS NOT NULL + AND TRIM(working_dir) != ''", + ) + .map_err(|e| format!("查询 agent_sessions 失败: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("读取 agent_sessions 失败: {e}"))?; + + for row in rows { + let (session_id, working_dir) = + row.map_err(|e| format!("解析 agent_session 行失败: {e}"))?; + let Some(target_path) = resolve_target_path(&working_dir)? else { + continue; + }; + if target_path == working_dir { + continue; + } + + session_updates.push((session_id, target_path)); + } + } + + for (session_id, target_path) in &session_updates { + conn.execute( + "UPDATE agent_sessions SET working_dir = ?1 WHERE id = ?2", + params![target_path, session_id], + ) + .map_err(|e| { + format!( + "更新 session working_dir 失败: session_id={}, working_dir={}: {e}", + session_id, target_path + ) + })?; + } + + Ok(( + workspace_updates.len(), + session_updates.len(), + skipped_workspaces, + )) +} + +fn find_workspace_id_by_root_path( + conn: &Connection, + root_path: &str, +) -> Result, String> { + let result = conn.query_row( + "SELECT id FROM workspaces WHERE root_path = ?1 LIMIT 1", + params![root_path], + |row| row.get::<_, String>(0), + ); + + match result { + Ok(workspace_id) => Ok(Some(workspace_id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(format!("查询重复 workspace 路径失败: {error}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema; + use tempfile::tempdir; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + schema::create_tables(&conn).expect("初始化表结构失败"); + conn + } + + #[test] + fn migrate_managed_workspace_paths_rewrites_proxycast_paths_and_copies_project() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("lime"); + let legacy_proxycast_root = temp.path().join("home").join(".proxycast"); + let legacy_project_root = legacy_proxycast_root.join("projects").join("default"); + std::fs::create_dir_all(&legacy_project_root).unwrap(); + std::fs::write(legacy_project_root.join("index.md"), "# proxycast").unwrap(); + + let conn = setup_test_db(); + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 1, '{}', 0, 0)", + params![ + "workspace-default", + "默认项目", + "general", + legacy_project_root.to_string_lossy().to_string(), + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, '{}', 0, 0)", + params!["workspace-custom", "外部项目", "persistent", "/tmp/custom-project"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + "session-default", + "gpt-5", + 0, + 0, + legacy_project_root.to_string_lossy().to_string(), + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5)", + params!["session-custom", "gpt-5", 0, 0, "/tmp/custom-project"], + ) + .unwrap(); + + let legacy_roots = vec![legacy_proxycast_root.clone()]; + let remap_path = |path: &Path| { + app_paths::migrate_managed_project_path_to_preferred_from_roots( + path, + &preferred_root, + &legacy_roots, + ) + }; + + let result = migrate_managed_workspace_paths_with_mapper(&conn, &remap_path) + .expect("执行受管项目路径迁移失败"); + + assert!(result.executed); + assert_eq!(result.migrated_workspaces, 1); + assert_eq!(result.migrated_sessions, 1); + assert_eq!(result.skipped_workspaces, 0); + + let migrated_root: String = conn + .query_row( + "SELECT root_path FROM workspaces WHERE id = ?1", + ["workspace-default"], + |row| row.get(0), + ) + .unwrap(); + let migrated_working_dir: String = conn + .query_row( + "SELECT working_dir FROM agent_sessions WHERE id = ?1", + ["session-default"], + |row| row.get(0), + ) + .unwrap(); + let custom_root: String = conn + .query_row( + "SELECT root_path FROM workspaces WHERE id = ?1", + ["workspace-custom"], + |row| row.get(0), + ) + .unwrap(); + + let expected_root = preferred_root.join("projects").join("default"); + assert_eq!(migrated_root, expected_root.to_string_lossy()); + assert_eq!(migrated_working_dir, expected_root.to_string_lossy()); + assert_eq!(custom_root, "/tmp/custom-project"); + assert_eq!( + std::fs::read_to_string(expected_root.join("index.md")).unwrap(), + "# proxycast" + ); + } + + #[test] + fn migrate_managed_workspace_paths_rewrites_compat_lime_paths_only_once() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("lime"); + let compat_root = temp.path().join("home").join(".lime"); + let compat_project_root = compat_root.join("projects").join("default"); + std::fs::create_dir_all(&compat_project_root).unwrap(); + + let conn = setup_test_db(); + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 1, '{}', 0, 0)", + params![ + "workspace-default", + "默认项目", + "general", + compat_project_root.to_string_lossy().to_string(), + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + "session-default", + "gpt-5", + 0, + 0, + compat_project_root.to_string_lossy().to_string(), + ], + ) + .unwrap(); + + let legacy_roots = vec![compat_root.clone()]; + let remap_path = |path: &Path| { + app_paths::migrate_managed_project_path_to_preferred_from_roots( + path, + &preferred_root, + &legacy_roots, + ) + }; + + let first = migrate_managed_workspace_paths_with_mapper(&conn, &remap_path) + .expect("首次执行受管项目路径迁移失败"); + let second = migrate_managed_workspace_paths_with_mapper(&conn, &remap_path) + .expect("重复执行受管项目路径迁移失败"); + + assert!(first.executed); + assert_eq!(first.migrated_workspaces, 1); + assert_eq!(first.migrated_sessions, 1); + assert!(!second.executed); + assert_eq!(second.migrated_workspaces, 0); + assert_eq!(second.migrated_sessions, 0); + } +} diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index 3bcd0deb2..5e3db590b 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -7,6 +7,7 @@ pub mod migration_v2; pub mod migration_v3; pub mod migration_v4; pub mod migration_v5; +pub mod migration_v6; pub mod schema; mod startup_migrations; pub mod system_providers; diff --git a/src-tauri/crates/core/src/database/startup_migrations.rs b/src-tauri/crates/core/src/database/startup_migrations.rs index 6d80ad2be..b71b26aeb 100644 --- a/src-tauri/crates/core/src/database/startup_migrations.rs +++ b/src-tauri/crates/core/src/database/startup_migrations.rs @@ -1,6 +1,6 @@ use rusqlite::Connection; -use super::{migration, migration_v2, migration_v3, migration_v4, migration_v5}; +use super::{migration, migration_v2, migration_v3, migration_v4, migration_v5, migration_v6}; pub(super) fn run_startup_migrations(conn: &Connection) { run_provider_pool_startup_migrations(conn); @@ -192,6 +192,20 @@ fn run_versioned_startup_migrations(conn: &Connection) { }) }, ); + + run_nonfatal_logged_startup_migration( + conn, + "受管项目路径迁移失败", + migration_v6::migrate_managed_workspace_paths, + |_, result| { + (result.executed || result.skipped_workspaces > 0).then(|| { + format!( + "[数据库] 受管项目路径迁移完成: workspaces={}, sessions={}, skipped={}", + result.migrated_workspaces, result.migrated_sessions, result.skipped_workspaces + ) + }) + }, + ); } #[cfg(test)] diff --git a/src-tauri/crates/core/src/database/system_providers.rs b/src-tauri/crates/core/src/database/system_providers.rs index 52fa8bba7..5913fac76 100644 --- a/src-tauri/crates/core/src/database/system_providers.rs +++ b/src-tauri/crates/core/src/database/system_providers.rs @@ -761,7 +761,7 @@ pub fn get_system_providers() -> Vec { id: "ollama", name: "Ollama", provider_type: ApiProviderType::Ollama, - api_host: "http://localhost:11434", + api_host: "http://127.0.0.1:11434", group: ProviderGroup::Local, sort_order: 56, api_version: None, diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index 3d2bb9c85..5dbeda366 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -1392,6 +1392,7 @@ impl ModelRegistryService { if normalized_provider == "ollama" || normalized_host.contains("ollama") || normalized_host.contains("://localhost:11434") + || normalized_host.contains("://127.0.0.1:11434") { return ModelFetchProtocol::Ollama; } @@ -1450,7 +1451,8 @@ impl ModelRegistryService { } fn build_ollama_models_api_url(api_host: &str) -> String { - let host = api_host.trim_end_matches('/'); + let normalized_host = Self::normalize_ollama_loopback_host(api_host); + let host = normalized_host.trim_end_matches('/'); if host.ends_with("/api/tags") { return host.to_string(); @@ -1463,6 +1465,38 @@ impl ModelRegistryService { format!("{host}/api/tags") } + fn normalize_ollama_loopback_host(api_host: &str) -> String { + let trimmed = api_host.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let had_scheme = trimmed.starts_with("http://") || trimmed.starts_with("https://"); + let parse_target = if had_scheme { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + + let Ok(mut url) = reqwest::Url::parse(&parse_target) else { + return trimmed.to_string(); + }; + + if matches!(url.host_str(), Some("localhost")) && url.set_host(Some("127.0.0.1")).is_err() { + return trimmed.to_string(); + } + + let normalized = url.to_string(); + if had_scheme { + normalized.trim_end_matches('/').to_string() + } else { + normalized + .trim_start_matches("http://") + .trim_end_matches('/') + .to_string() + } + } + fn build_diagnostic_models_api_url( provider_id: &str, api_host: &str, @@ -1614,15 +1648,19 @@ impl ModelRegistryService { let request = Self::prepare_model_fetch_request(provider_id, api_host, api_key, provider_type)?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| { - ModelsApiError::new( - ModelFetchErrorKind::Other, - format!("创建 HTTP 客户端失败: {e}"), - ) - })?; + let mut client_builder = + reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); + if Self::should_bypass_proxy_for_models_api_url(&request.url) { + tracing::info!("[ModelRegistry] 本地模型地址绕过系统代理: {}", request.url); + client_builder = client_builder.no_proxy(); + } + + let client = client_builder.build().map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::Other, + format!("创建 HTTP 客户端失败: {e}"), + ) + })?; let models = match request.protocol { ModelFetchProtocol::OpenAiCompatible => { @@ -1922,6 +1960,27 @@ impl ModelRegistryService { updated_at: now, } } + + fn should_bypass_proxy_for_models_api_url(url: &str) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + + let Some(host) = parsed.host_str() else { + return false; + }; + + if matches!( + host, + "localhost" | "127.0.0.1" | "::1" | "0.0.0.0" | "host.docker.internal" + ) { + return true; + } + + host.parse::() + .map(|ip| ip.is_loopback() || ip.is_unspecified()) + .unwrap_or(false) + } } // ============================================================================ @@ -2127,11 +2186,43 @@ mod tests { fn test_build_ollama_models_api_url() { assert_eq!( ModelRegistryService::build_ollama_models_api_url("http://localhost:11434"), - "http://localhost:11434/api/tags" + "http://127.0.0.1:11434/api/tags" ); assert_eq!( ModelRegistryService::build_ollama_models_api_url("http://localhost:11434/api"), - "http://localhost:11434/api/tags" + "http://127.0.0.1:11434/api/tags" + ); + assert_eq!( + ModelRegistryService::build_ollama_models_api_url("http://127.0.0.1:11434"), + "http://127.0.0.1:11434/api/tags" + ); + assert_eq!( + ModelRegistryService::build_ollama_models_api_url("http://127.0.0.1:11434/api"), + "http://127.0.0.1:11434/api/tags" + ); + } + + #[test] + fn test_should_bypass_proxy_for_models_api_url() { + assert!( + ModelRegistryService::should_bypass_proxy_for_models_api_url( + "http://127.0.0.1:11434/api/tags" + ) + ); + assert!( + ModelRegistryService::should_bypass_proxy_for_models_api_url( + "http://localhost:11434/api/tags" + ) + ); + assert!( + ModelRegistryService::should_bypass_proxy_for_models_api_url( + "http://[::1]:11434/api/tags" + ) + ); + assert!( + !ModelRegistryService::should_bypass_proxy_for_models_api_url( + "https://api.openai.com/v1/models" + ) ); } @@ -2272,7 +2363,7 @@ mod tests { )); assert!(!ModelRegistryService::requires_api_key_for_model_fetch( "ollama", - "http://localhost:11434", + "http://127.0.0.1:11434", ApiProviderType::Ollama )); assert!(!ModelRegistryService::requires_api_key_for_model_fetch( diff --git a/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js b/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js index 7334001fc..d94e52a8f 100644 --- a/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js +++ b/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js @@ -782,6 +782,113 @@ async (args, helpers) => { return uniqueCandidates.flatMap((candidate) => serializeCodeBlock(candidate)); } + function extractArticleMarkdown(articleRoot, richTextRoot) { + const state = createState(); + const articleSegments = collectArticleSegments(articleRoot, richTextRoot); + const fallbackCodeBlocks = collectFallbackCodeBlocks(articleRoot); + const potentialMediaCarriers = collectPotentialMediaCarriers(articleRoot); + let markdown = serializeArticleSegments(articleSegments, state); + markdown = appendMissingBlocks(markdown, fallbackCodeBlocks); + markdown = appendMissingBlocks(markdown, collectImageMarkdown(articleRoot, state)); + + return { + state, + fallbackCodeBlocks, + potentialMediaCarriers, + markdown, + }; + } + + function shouldAttemptStructuredContentRecovery(articleRoot, extraction) { + if (!extraction?.markdown) { + return false; + } + + if (extraction.state.images.length > 0) { + return false; + } + + if (extraction.fallbackCodeBlocks.length > 0) { + return false; + } + + if (countExtractedCodeBlocks(extraction.markdown) > 0) { + return false; + } + + const snapshot = buildWarmupSnapshot(articleRoot); + return snapshot.textLength >= 1800 && snapshot.articleBlocks >= 1; + } + + function safeWindowScrollTo(x, y) { + if (typeof window?.scrollTo !== "function") { + return false; + } + + const userAgent = String(globalThis?.navigator?.userAgent || "").toLowerCase(); + if (userAgent.includes("jsdom")) { + return false; + } + + try { + window.scrollTo(x, y); + return true; + } catch { + return false; + } + } + + async function attemptStructuredContentRecovery(articleRoot) { + if (!(articleRoot instanceof Element)) { + return; + } + + const initialScrollY = Number.isFinite(window?.scrollY) + ? Math.round(window.scrollY) + : 0; + if (safeWindowScrollTo(0, 0)) { + await helpers.sleep?.(80); + } + + for (let attempt = 0; attempt < 2; attempt += 1) { + await helpers.waitForDomStable?.({ + root: articleRoot, + stableMs: 650, + timeoutMs: 4200, + }); + await helpers.scrollUntilSettled?.({ + root: articleRoot, + maxScrolls: 18, + delayMs: 420, + settleRounds: 1, + viewportFactor: 0.95, + getSnapshot: () => buildWarmupSnapshot(articleRoot), + }); + await helpers.waitForDomStable?.({ + root: articleRoot, + stableMs: 700, + timeoutMs: 4600, + }); + await helpers.waitForImagesReady?.(articleRoot, { + timeoutMs: 4200, + intervalMs: 250, + stableRounds: 3, + }); + + const snapshot = buildWarmupSnapshot(articleRoot); + if (snapshot.mediaCarriers > 0 || snapshot.codeBlocks > 0) { + break; + } + } + + const currentScrollY = Number.isFinite(window?.scrollY) + ? Math.round(window.scrollY) + : initialScrollY; + if (currentScrollY !== initialScrollY && safeWindowScrollTo(0, initialScrollY)) { + await helpers.sleep?.(80); + } + } + function collectPotentialMediaCarriers(root) { if (!(root instanceof Element)) { return []; @@ -937,13 +1044,25 @@ async (args, helpers) => { }; } - const state = createState(); - const articleSegments = collectArticleSegments(articleRoot, richTextRoot); - const fallbackCodeBlocks = collectFallbackCodeBlocks(articleRoot); - const potentialMediaCarriers = collectPotentialMediaCarriers(articleRoot); - let markdown = serializeArticleSegments(articleSegments, state); - markdown = appendMissingBlocks(markdown, fallbackCodeBlocks); - markdown = appendMissingBlocks(markdown, collectImageMarkdown(articleRoot, state)); + let extraction = extractArticleMarkdown(articleRoot, richTextRoot); + if (shouldAttemptStructuredContentRecovery(articleRoot, extraction)) { + await attemptStructuredContentRecovery(articleRoot); + const recoveredExtraction = extractArticleMarkdown(articleRoot, richTextRoot); + const improved = + recoveredExtraction.state.images.length > extraction.state.images.length || + recoveredExtraction.fallbackCodeBlocks.length > + extraction.fallbackCodeBlocks.length || + countExtractedCodeBlocks(recoveredExtraction.markdown) > + countExtractedCodeBlocks(extraction.markdown); + if (improved) { + extraction = recoveredExtraction; + } + } + + const state = extraction.state; + const fallbackCodeBlocks = extraction.fallbackCodeBlocks; + const potentialMediaCarriers = extraction.potentialMediaCarriers; + let markdown = extraction.markdown; if (!markdown) { return { ok: false, diff --git a/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.test.ts b/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.test.ts index c17457ff2..d4f9156d7 100644 --- a/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.test.ts +++ b/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it } from "vitest"; const runnerSource = fs.readFileSync( path.resolve( - process.cwd(), + globalThis.process?.cwd() || "", "src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js", ), "utf8", @@ -295,6 +295,77 @@ description: Generates API documentation from source. expect(result.data?.images).toHaveLength(1); }); + it("会在长文先渲染纯文本骨架时执行二次预热并补回图片与代码块", async () => { + const repeatedParagraph = + "This section explains why agents need structured skills, reusable templates, and review gates. ".repeat( + 24, + ); + document.body.innerHTML = ` +
+
Structured recovery export
+
+
+
${repeatedParagraph}
+
## Pattern 1: The Tool Wrapper
+
Here is the first rich example block:
+
+
## Pattern 2: The Generator
+
${repeatedParagraph}
+
+
+
+ `; + + let scrollCallCount = 0; + const result = await xArticleExportRunner( + { + url: "https://x.com/GoogleCloudTech/article/2033953579824758855", + }, + createHelpers({ + async scrollUntilSettled() { + scrollCallCount += 1; + if (scrollCallCount === 2) { + const lateRichContent = document.querySelector("#late-rich-content"); + if (lateRichContent) { + lateRichContent.innerHTML = ` +
+ Late skill diagram +
+
# skills/late-loader/SKILL.md +--- +name: late-loader +description: Recovers delayed rich content after a second warmup pass.
+ `; + } + } + return { + scrolls: 6, + scrollY: 1200, + scrollHeight: 4200, + stableCount: 1, + }; + }, + }), + ); + + expect(scrollCallCount).toBeGreaterThanOrEqual(2); + expect(result.ok).toBe(true); + expect(result.data?.markdown).toContain("![Late skill diagram]"); + expect(result.data?.markdown).toContain("```markdown"); + expect(result.data?.markdown).toContain("# skills/late-loader/SKILL.md"); + expect(result.data?.images).toHaveLength(1); + expect(result.data?.images?.[0]?.url).toContain( + "https://pbs.twimg.com/media/late-skill-diagram?format=png&name=orig", + ); + }); + it("能兼容真实 X 长文常见的 twitterArticleRichTextView 结构并保留封面图", async () => { document.body.innerHTML = `
diff --git a/src-tauri/src/app/commands/config.rs b/src-tauri/src/app/commands/config.rs index 520b1c435..8336e48f2 100644 --- a/src-tauri/src/app/commands/config.rs +++ b/src-tauri/src/app/commands/config.rs @@ -406,7 +406,10 @@ fn build_provider_env_vars( ] } // Ollama 本地部署 - "ollama" => vec![("OLLAMA_BASE_URL".to_string(), api_host.to_string())], + "ollama" => vec![ + ("OLLAMA_BASE_URL".to_string(), api_host.to_string()), + ("OLLAMA_HOST".to_string(), api_host.to_string()), + ], _ => { // 未知类型尽量按已注册 ApiProviderType 的协议族生成环境变量 if let Ok(api_type) = provider_type.parse::() { 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 36e3926a9..bdcbb94cb 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 @@ -39,13 +39,17 @@ pub async fn aster_agent_init( pub async fn aster_agent_configure_provider( state: State<'_, AsterAgentState>, db: State<'_, DbConnection>, - request: ConfigureProviderRequest, + mut request: ConfigureProviderRequest, session_id: String, ) -> Result { + let runtime_tool_call_decision = + enrich_provider_config_with_runtime_tool_strategy(&mut request).await; tracing::info!( - "[AsterAgent] 配置 Provider: {} / {}", + "[AsterAgent] 配置 Provider: {} / {},tool_call_strategy={:?},toolshim_model={:?}", request.provider_name, - request.model_name + request.model_name, + runtime_tool_call_decision.strategy, + runtime_tool_call_decision.toolshim_model ); let provider_selector = request @@ -61,6 +65,11 @@ pub async fn aster_agent_configure_provider( credential_uuid: None, force_responses_api: false, credential_path: None, + toolshim: matches!( + request.tool_call_strategy, + Some(RuntimeToolCallStrategy::ToolShim) + ), + toolshim_model: request.toolshim_model.clone(), }; state diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index b4c7f2c69..54dcfe0ca 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -1,5 +1,6 @@ use super::*; use chrono::{DateTime, Utc}; +use lime_core::models::model_registry::ModelCapabilities; /// Aster Agent 状态信息 #[derive(Debug, Serialize)] @@ -26,6 +27,12 @@ pub struct ConfigureProviderRequest { pub api_key: Option, #[serde(default)] pub base_url: Option, + #[serde(default, alias = "modelCapabilities")] + pub model_capabilities: Option, + #[serde(default, alias = "toolCallStrategy")] + pub tool_call_strategy: Option, + #[serde(default, alias = "toolshimModel")] + pub toolshim_model: Option, } /// 从凭证池配置 Provider 的请求 diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 544f7b416..a546afe5c 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_strategy; mod reply_runtime; mod report_skill_launch; mod request_model_resolution; @@ -401,6 +402,9 @@ pub(crate) use prompt_context::{ merge_system_prompt_with_service_skill_launch_preload, merge_system_prompt_with_team_preference, }; +pub(crate) use provider_runtime_strategy::{ + enrich_provider_config_with_runtime_tool_strategy, RuntimeToolCallStrategy, +}; #[cfg(test)] use reply_runtime::message_suggests_live_search; use reply_runtime::{ diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index bd8052344..660c93f22 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -579,6 +579,48 @@ fn build_markdown_bundle_translation_followup( lines } +fn build_markdown_bundle_source_material_followup( + execution: &ServiceSkillLaunchPreloadExecution, +) -> Vec { + if !execution.result.ok { + return Vec::new(); + } + + let Some(saved_content) = execution.result.saved_content.as_ref() else { + return Vec::new(); + }; + let Some(markdown_relative_path) = saved_content + .markdown_relative_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Vec::new(); + }; + + let export_kind = execution + .result + .data + .as_ref() + .and_then(serde_json::Value::as_object) + .and_then(|data| extract_object_string(data, &["export_kind", "exportKind"])); + if export_kind.as_deref() != Some("markdown_bundle") { + return Vec::new(); + } + + let markdown_path = build_prompt_file_path( + saved_content.project_root_path.as_deref(), + markdown_relative_path, + ); + vec![ + format!("- 当前已导出的 Markdown bundle 位于 {markdown_path}。"), + "- 这个 bundle 是系统侧采集得到的源材料,不默认等于用户要的最终交付结果。".to_string(), + "- 如果用户当前目标是继续提炼、分析、改写、生成技能包、报告、方案、脚本或其他正式成果,必须先基于这份已保存的 Markdown 继续完成原任务,而不是只重复导出成功摘要。".to_string(), + "- 后续处理优先使用本地文件工具(Read / Write / Edit / Glob)围绕已保存 bundle 展开;不要再次抓站点,也不要把保存路径、图片数量或采集摘要原样复述后就停止。".to_string(), + "- 除非当前任务本身就是翻译、校对或回写源 Markdown,否则不要把 exports 下的源 bundle 直接当成最终结果目录;需要新增正式结果时,应另外写出真正可交付的工作区文件。".to_string(), + ] +} + fn build_service_skill_launch_preload_prompt( execution: &ServiceSkillLaunchPreloadExecution, ) -> String { @@ -652,6 +694,7 @@ fn build_service_skill_launch_preload_prompt( format!("- 已预执行请求(JSON):{request_json}。"), format!("- 已预执行结果(JSON):{result_json}。"), ]; + lines.extend(build_markdown_bundle_source_material_followup(execution)); lines.extend(build_markdown_bundle_translation_followup(execution)); lines.push( "- 除非用户明确要求“重跑一次 / 换关键词 / 换筛选条件 / 重新抓取”,否则本回合不要再次调用任何站点执行工具。" 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 new file mode 100644 index 000000000..820b3f81c --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/provider_runtime_strategy.rs @@ -0,0 +1,409 @@ +use super::dto::ConfigureProviderRequest; +use lime_core::models::model_registry::ModelCapabilities; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::time::Duration; +use url::Url; + +const OLLAMA_RUNTIME_PROBE_TIMEOUT_SECS: u64 = 5; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RuntimeToolCallStrategy { + Native, + ToolShim, +} + +#[derive(Debug, Clone)] +pub(crate) struct RuntimeToolCallDecision { + pub capabilities: ModelCapabilities, + pub strategy: RuntimeToolCallStrategy, + pub toolshim_model: Option, +} + +#[derive(Debug, Deserialize)] +struct OllamaShowResponse { + #[serde(default)] + capabilities: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaTagsResponse { + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaTagModel { + name: String, +} + +fn normalize_provider_identity(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +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))) + .is_some_and(|identity| identity == "ollama") +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + let trimmed = value?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +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://") { + candidate + } else { + format!("http://{candidate}") + }; + + let mut parsed = Url::parse(&raw).unwrap_or_else(|_| { + Url::parse("http://127.0.0.1:11434").expect("hardcoded ollama fallback url is valid") + }); + + if matches!(parsed.host_str(), Some("localhost")) { + let _ = parsed.set_host(Some("127.0.0.1")); + } + if parsed.port().is_none() && parsed.scheme() == "http" { + let _ = parsed.set_port(Some(11434)); + } + + let trimmed_path = parsed.path().trim_end_matches('/').to_string(); + if trimmed_path.is_empty() { + parsed.set_path(""); + } else { + parsed.set_path(&trimmed_path); + } + parsed.to_string().trim_end_matches('/').to_string() +} + +fn default_runtime_model_capabilities() -> ModelCapabilities { + ModelCapabilities { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: false, + } +} + +fn build_runtime_tool_call_decision( + provider_selector: Option<&str>, + provider_name: &str, + model_name: &str, + capabilities: ModelCapabilities, + toolshim_model: Option, +) -> RuntimeToolCallDecision { + let supports_native_tools = capabilities.tools || capabilities.function_calling; + if is_ollama_provider(provider_selector, provider_name) && !supports_native_tools { + return RuntimeToolCallDecision { + capabilities, + strategy: RuntimeToolCallStrategy::ToolShim, + toolshim_model: Some(toolshim_model.unwrap_or_else(|| model_name.to_string())), + }; + } + + RuntimeToolCallDecision { + capabilities, + strategy: RuntimeToolCallStrategy::Native, + toolshim_model: None, + } +} + +fn parse_ollama_show_capabilities( + response: OllamaShowResponse, + fallback: Option<&ModelCapabilities>, +) -> ModelCapabilities { + let capability_set = response + .capabilities + .into_iter() + .map(|capability| capability.trim().to_ascii_lowercase()) + .collect::>(); + let fallback = fallback.cloned().unwrap_or_default(); + let supports_tools = capability_set.contains("tools"); + + ModelCapabilities { + vision: capability_set.contains("vision") || fallback.vision, + tools: supports_tools, + streaming: true, + json_mode: supports_tools || fallback.json_mode, + function_calling: supports_tools, + reasoning: capability_set.contains("thinking") || fallback.reasoning, + } +} + +async fn fetch_ollama_show_capabilities( + client: &reqwest::Client, + base_url: &str, + model_name: &str, + fallback: Option<&ModelCapabilities>, +) -> Option { + let url = format!("{base_url}/api/show"); + let response = client + .post(url) + .json(&serde_json::json!({ "name": model_name })) + .send() + .await + .ok()?; + let response = response.error_for_status().ok()?; + let parsed = response.json::().await.ok()?; + Some(parse_ollama_show_capabilities(parsed, fallback)) +} + +async fn fetch_ollama_toolshim_interpreter_model( + client: &reqwest::Client, + base_url: &str, + selected_model: &str, +) -> Option { + let url = format!("{base_url}/api/tags"); + let response = client.get(url).send().await.ok()?; + let response = response.error_for_status().ok()?; + let parsed = response.json::().await.ok()?; + + for model in parsed + .models + .into_iter() + .map(|model| model.name) + .filter(|name| { + normalize_provider_identity(name) != normalize_provider_identity(selected_model) + }) + { + let Some(capabilities) = + fetch_ollama_show_capabilities(client, base_url, &model, None).await + else { + continue; + }; + if capabilities.tools || capabilities.function_calling { + return Some(model); + } + } + + None +} + +pub(crate) fn resolve_runtime_tool_call_decision_from_capabilities( + provider_selector: Option<&str>, + provider_name: &str, + model_name: &str, + capabilities: Option<&ModelCapabilities>, +) -> RuntimeToolCallDecision { + build_runtime_tool_call_decision( + provider_selector, + provider_name, + model_name, + capabilities + .cloned() + .unwrap_or_else(default_runtime_model_capabilities), + None, + ) +} + +pub(crate) async fn resolve_runtime_tool_call_decision( + provider_selector: Option<&str>, + provider_name: &str, + model_name: &str, + base_url: Option<&str>, + capabilities: Option<&ModelCapabilities>, +) -> RuntimeToolCallDecision { + if !is_ollama_provider(provider_selector, provider_name) { + return resolve_runtime_tool_call_decision_from_capabilities( + provider_selector, + provider_name, + model_name, + capabilities, + ); + } + + let fallback_capabilities = capabilities + .cloned() + .unwrap_or_else(default_runtime_model_capabilities); + let base_url = normalize_ollama_base_url(base_url); + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(OLLAMA_RUNTIME_PROBE_TIMEOUT_SECS)) + .build() + { + Ok(client) => client, + Err(error) => { + tracing::warn!( + "[AsterAgent] 创建 Ollama 运行时能力探测客户端失败,回退 catalog 能力: {}", + error + ); + return build_runtime_tool_call_decision( + provider_selector, + provider_name, + model_name, + fallback_capabilities, + None, + ); + } + }; + + let Some(live_capabilities) = fetch_ollama_show_capabilities( + &client, + &base_url, + model_name, + Some(&fallback_capabilities), + ) + .await + else { + tracing::warn!( + "[AsterAgent] 读取 Ollama 模型能力失败,回退 catalog 能力: model={}, base_url={}", + model_name, + base_url + ); + return build_runtime_tool_call_decision( + provider_selector, + provider_name, + model_name, + fallback_capabilities, + None, + ); + }; + + let toolshim_model = if live_capabilities.tools || live_capabilities.function_calling { + None + } else { + fetch_ollama_toolshim_interpreter_model(&client, &base_url, model_name) + .await + .or_else(|| Some(model_name.to_string())) + }; + + build_runtime_tool_call_decision( + provider_selector, + provider_name, + model_name, + live_capabilities, + toolshim_model, + ) +} + +pub(crate) async fn enrich_provider_config_with_runtime_tool_strategy( + provider_config: &mut ConfigureProviderRequest, +) -> RuntimeToolCallDecision { + let decision = resolve_runtime_tool_call_decision( + provider_config.provider_id.as_deref(), + &provider_config.provider_name, + &provider_config.model_name, + provider_config.base_url.as_deref(), + provider_config.model_capabilities.as_ref(), + ) + .await; + + provider_config.model_capabilities = Some(decision.capabilities.clone()); + provider_config.tool_call_strategy = Some(decision.strategy); + provider_config.toolshim_model = decision.toolshim_model.clone(); + + decision +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_strategy_keeps_tool_capable_model_on_native_path() { + let capabilities = ModelCapabilities { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: false, + }; + + let decision = resolve_runtime_tool_call_decision_from_capabilities( + Some("ollama"), + "ollama", + "glm-5.1:cloud", + Some(&capabilities), + ); + + assert_eq!(decision.strategy, RuntimeToolCallStrategy::Native); + assert!(decision.toolshim_model.is_none()); + assert!(decision.capabilities.tools); + } + + #[test] + fn toolshim_strategy_wraps_ollama_model_without_native_tools() { + let capabilities = ModelCapabilities { + vision: false, + tools: false, + streaming: true, + json_mode: false, + function_calling: false, + reasoning: true, + }; + + let decision = resolve_runtime_tool_call_decision_from_capabilities( + Some("ollama"), + "ollama", + "deepseek-r1:latest", + Some(&capabilities), + ); + + assert_eq!(decision.strategy, RuntimeToolCallStrategy::ToolShim); + assert_eq!( + decision.toolshim_model.as_deref(), + Some("deepseek-r1:latest") + ); + assert!(!decision.capabilities.tools); + } + + #[test] + fn parse_ollama_show_capabilities_uses_live_capabilities() { + let parsed = parse_ollama_show_capabilities( + OllamaShowResponse { + capabilities: vec![ + "completion".to_string(), + "thinking".to_string(), + "tools".to_string(), + ], + }, + None, + ); + + assert!(parsed.tools); + assert!(parsed.function_calling); + assert!(parsed.reasoning); + assert!(parsed.json_mode); + } + + #[tokio::test] + async fn enrich_provider_config_sets_runtime_strategy_fields() { + let mut provider_config = ConfigureProviderRequest { + provider_id: Some("openai".to_string()), + provider_name: "openai".to_string(), + model_name: "gpt-4o".to_string(), + api_key: None, + base_url: None, + model_capabilities: None, + tool_call_strategy: None, + toolshim_model: None, + }; + + let decision = + enrich_provider_config_with_runtime_tool_strategy(&mut provider_config).await; + + assert_eq!(decision.strategy, RuntimeToolCallStrategy::Native); + assert_eq!( + provider_config.tool_call_strategy, + Some(RuntimeToolCallStrategy::Native) + ); + assert!(provider_config.toolshim_model.is_none()); + assert!(provider_config + .model_capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.tools)); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index 222ab8fb9..bd60b6cd5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -1,5 +1,6 @@ use super::*; use crate::commands::model_registry_cmd::ModelRegistryState; +use lime_core::database::dao::api_key_provider::{ApiProviderType, ProviderGroup}; use lime_core::models::model_registry::{ EnhancedModelMetadata, ModelCapabilities, ModelSource, ModelTier, ProviderAliasConfig, }; @@ -9,11 +10,22 @@ use tauri::Manager; #[derive(Debug, Clone)] struct ProviderResolutionContext { provider_selector: String, + aster_provider_name: String, compatibility_provider_key: String, registry_provider_ids: Vec, alias_key: String, custom_models: Vec, is_custom_provider: bool, + provider_type: Option, + provider_group: Option, + configured_api_host: Option, + has_credentials: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RuntimeProviderConfigurationStrategy { + Manual { base_url: Option }, + CredentialPool, } fn normalize_identifier(value: &str) -> String { @@ -45,6 +57,77 @@ fn provider_registry_id_from_key(provider_key: &str) -> String { } } +fn provider_type_from_key(provider_key: &str) -> Option { + match normalize_identifier(provider_key).as_str() { + "openai" | "iflow" => Some(ApiProviderType::Openai), + "anthropic" | "claude" | "claude_oauth" => Some(ApiProviderType::Anthropic), + "anthropic-compatible" => Some(ApiProviderType::AnthropicCompatible), + "gemini" | "gemini_api_key" => Some(ApiProviderType::Gemini), + "azure-openai" => Some(ApiProviderType::AzureOpenai), + "vertexai" => Some(ApiProviderType::Vertexai), + "aws-bedrock" | "bedrock" => Some(ApiProviderType::AwsBedrock), + "ollama" => Some(ApiProviderType::Ollama), + "fal" => Some(ApiProviderType::Fal), + "new-api" => Some(ApiProviderType::NewApi), + "gateway" => Some(ApiProviderType::Gateway), + "codex" => Some(ApiProviderType::Codex), + _ => None, + } +} + +fn normalize_runtime_provider_base_url( + provider_type: Option, + base_url: Option, +) -> Option { + let normalized = normalize_optional_text(base_url)?; + if provider_type != Some(ApiProviderType::Ollama) { + return Some(normalized); + } + + let trimmed = normalized.trim_end_matches('/').to_string(); + if let Some(without_version) = trimmed.strip_suffix("/v1") { + return normalize_optional_text(Some(without_version.to_string())).or_else(|| { + Some( + ApiProviderType::Ollama + .runtime_spec() + .default_api_host + .to_string(), + ) + }); + } + + Some(trimmed) +} + +fn resolve_runtime_provider_configuration_strategy( + context: &ProviderResolutionContext, +) -> RuntimeProviderConfigurationStrategy { + let configured_base_url = normalize_runtime_provider_base_url( + context.provider_type, + context.configured_api_host.clone(), + ); + let is_credentialless_local_provider = + matches!(context.provider_group, Some(ProviderGroup::Local)) && !context.has_credentials; + let should_use_manual_provider = + is_credentialless_local_provider || context.provider_type == Some(ApiProviderType::Ollama); + + if !should_use_manual_provider { + return RuntimeProviderConfigurationStrategy::CredentialPool; + } + + let fallback_base_url = context.provider_type.map(|provider_type| { + normalize_runtime_provider_base_url( + Some(provider_type), + Some(provider_type.runtime_spec().default_api_host.to_string()), + ) + .unwrap_or_else(|| provider_type.runtime_spec().default_api_host.to_string()) + }); + + RuntimeProviderConfigurationStrategy::Manual { + base_url: configured_base_url.or(fallback_base_url), + } +} + fn infer_reasoning_capability(model_id: &str) -> bool { let normalized = normalize_identifier(model_id); normalized.contains("thinking") || normalized.contains("reasoning") @@ -227,18 +310,37 @@ fn build_provider_resolution_context( let provider_selector = normalize_identifier(provider_selector); let is_custom_provider = lime_core::models::provider_type::is_custom_provider_id(&provider_selector); + let mut provider_type = provider_type_from_key(&provider_selector); + let mut aster_provider_name = provider_type + .map(|provider_type| provider_type.runtime_spec().aster_provider_name.to_string()) + .unwrap_or_else(|| provider_selector.clone()); let mut compatibility_provider_key = provider_selector.clone(); let mut registry_provider_ids = vec![ provider_selector.clone(), provider_registry_id_from_key(&provider_selector), ]; let mut custom_models = Vec::new(); + let mut provider_group = None; + let mut configured_api_host = None; + let mut has_credentials = false; - if is_custom_provider { - if let Some(provider_with_keys) = api_key_provider_service - .0 - .get_provider(db, &provider_selector)? - { + if let Some(provider_with_keys) = api_key_provider_service + .0 + .get_provider(db, &provider_selector)? + { + provider_type = Some(provider_with_keys.provider.provider_type); + aster_provider_name = provider_with_keys + .provider + .provider_type + .runtime_spec() + .aster_provider_name + .to_string(); + provider_group = Some(provider_with_keys.provider.group); + configured_api_host = + normalize_optional_text(Some(provider_with_keys.provider.api_host.clone())); + has_credentials = !provider_with_keys.api_keys.is_empty(); + + if is_custom_provider { compatibility_provider_key = provider_with_keys.provider.provider_type.to_string(); registry_provider_ids.push(provider_registry_id_from_key(&compatibility_provider_key)); custom_models = provider_with_keys.provider.custom_models; @@ -251,10 +353,15 @@ fn build_provider_resolution_context( }); Ok(ProviderResolutionContext { + aster_provider_name, alias_key: provider_alias_config_key(&provider_selector), compatibility_provider_key, custom_models, + configured_api_host, + has_credentials, is_custom_provider, + provider_group, + provider_type, provider_selector, registry_provider_ids, }) @@ -904,18 +1011,38 @@ pub(super) async fn resolve_runtime_request_provider_config( ); } + let provider_strategy = resolve_runtime_provider_configuration_strategy(&context); + let base_url = match provider_strategy { + RuntimeProviderConfigurationStrategy::Manual { base_url } => base_url, + RuntimeProviderConfigurationStrategy::CredentialPool => None, + }; + let model_capabilities = find_model_meta(&resolved_model, &catalog) + .map(|model| model.capabilities.clone()) + .unwrap_or_else(|| { + infer_model_capabilities( + &resolved_model, + Some(&context.provider_selector), + None, + None, + ) + }); + Ok(Some(ConfigureProviderRequest { provider_id: Some(context.provider_selector.clone()), - provider_name: context.provider_selector, + provider_name: context.aster_provider_name, model_name: resolved_model, api_key: None, - base_url: None, + base_url, + model_capabilities: Some(model_capabilities), + tool_call_strategy: None, + toolshim_model: None, })) } #[cfg(test)] mod tests { use super::*; + use lime_core::database::dao::api_key_provider::ProviderGroup; fn build_model( id: &str, @@ -1214,4 +1341,39 @@ mod tests { ("gemini".to_string(), RequestPreferenceSource::Request) ); } + + #[test] + fn runtime_provider_strategy_prefers_manual_mode_for_credentialless_local_provider() { + let context = ProviderResolutionContext { + provider_selector: "ollama".to_string(), + aster_provider_name: "ollama".to_string(), + compatibility_provider_key: "ollama".to_string(), + registry_provider_ids: vec!["ollama".to_string()], + alias_key: "ollama".to_string(), + custom_models: vec![], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Ollama), + provider_group: Some(ProviderGroup::Local), + configured_api_host: Some("http://127.0.0.1:11434".to_string()), + has_credentials: false, + }; + + assert_eq!( + resolve_runtime_provider_configuration_strategy(&context), + RuntimeProviderConfigurationStrategy::Manual { + base_url: Some("http://127.0.0.1:11434".to_string()), + } + ); + } + + #[test] + fn normalize_runtime_provider_base_url_strips_ollama_v1_suffix() { + assert_eq!( + normalize_runtime_provider_base_url( + Some(ApiProviderType::Ollama), + Some("http://127.0.0.1:11434/v1/".to_string()), + ), + Some("http://127.0.0.1:11434".to_string()) + ); + } } 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 38ac2c383..dcede072a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -13,6 +13,37 @@ const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderConfigApplyMode { + Direct, + CredentialPool, +} + +fn normalize_provider_identity(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn resolve_provider_config_apply_mode( + provider_config: &ConfigureProviderRequest, +) -> ProviderConfigApplyMode { + if provider_config.api_key.is_some() || provider_config.base_url.is_some() { + return ProviderConfigApplyMode::Direct; + } + + let provider_selector = provider_config + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name); + let normalized_selector = normalize_provider_identity(provider_selector); + let normalized_provider_name = normalize_provider_identity(&provider_config.provider_name); + + if normalized_selector == "ollama" || normalized_provider_name == "ollama" { + return ProviderConfigApplyMode::Direct; + } + + ProviderConfigApplyMode::CredentialPool +} + fn emit_runtime_side_event( app: &AppHandle, event_name: &str, @@ -441,6 +472,21 @@ async fn execute_aster_chat_request( if let Some(resolved_provider_config) = resolved_provider_config { request.provider_config = Some(resolved_provider_config); } + if let Some(provider_config) = request.provider_config.as_mut() { + let runtime_tool_call_decision = + enrich_provider_config_with_runtime_tool_strategy(provider_config).await; + tracing::info!( + "[AsterAgent] provider_config 运行时工具策略: provider_id={:?}, provider_name={}, model_name={}, strategy={:?}, toolshim_model={:?}, tools={}, function_calling={}, reasoning={}", + provider_config.provider_id, + provider_config.provider_name, + provider_config.model_name, + runtime_tool_call_decision.strategy, + runtime_tool_call_decision.toolshim_model, + runtime_tool_call_decision.capabilities.tools, + runtime_tool_call_decision.capabilities.function_calling, + runtime_tool_call_decision.capabilities.reasoning + ); + } normalize_runtime_turn_request_metadata( &mut request, session_recent_harness_context.theme.as_deref(), @@ -1015,6 +1061,7 @@ async fn execute_aster_chat_request( provider_config.api_key.is_some(), provider_config.base_url ); + let apply_mode = resolve_provider_config_apply_mode(provider_config); let config = ProviderConfig { provider_name: provider_config.provider_name.clone(), provider_selector: provider_config @@ -1027,31 +1074,39 @@ async fn execute_aster_chat_request( credential_uuid: None, force_responses_api: false, credential_path: None, + toolshim: matches!( + provider_config.tool_call_strategy, + Some(RuntimeToolCallStrategy::ToolShim) + ), + toolshim_model: provider_config.toolshim_model.clone(), }; - // 如果前端提供了 api_key,直接使用;否则从凭证池选择凭证 - if provider_config.api_key.is_some() { - state.configure_provider(config, session_id, db).await?; - let provider_selector = provider_config - .provider_id - .as_deref() - .unwrap_or(&provider_config.provider_name); - persist_session_provider_routing(session_id, provider_selector).await?; - } else { - // 没有 api_key,使用凭证池(优先 provider_id,其次 provider_name) - let provider_selector = provider_config - .provider_id - .as_deref() - .unwrap_or(&provider_config.provider_name); - state - .configure_provider_from_pool( - db, - provider_selector, - &provider_config.model_name, - session_id, - ) - .await?; - persist_session_provider_routing(session_id, provider_selector).await?; + let provider_selector = provider_config + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name); + tracing::info!( + "[AsterAgent] provider_config 应用策略: provider_selector={}, mode={:?}, tool_call_strategy={:?}, toolshim_model={:?}", + provider_selector, + apply_mode, + provider_config.tool_call_strategy, + provider_config.toolshim_model + ); + match apply_mode { + ProviderConfigApplyMode::Direct => { + state.configure_provider(config, session_id, db).await?; + } + ProviderConfigApplyMode::CredentialPool => { + state + .configure_provider_from_pool( + db, + provider_selector, + &provider_config.model_name, + session_id, + ) + .await?; + } } + persist_session_provider_routing(session_id, provider_selector).await?; } // 检查 Provider 是否已配置 @@ -2738,6 +2793,25 @@ mod tests { ); } + #[test] + fn resolve_provider_config_apply_mode_prefers_direct_for_ollama_without_api_key() { + let provider_config = ConfigureProviderRequest { + provider_id: Some("ollama".to_string()), + provider_name: "ollama".to_string(), + model_name: "deepseek-r1:latest".to_string(), + api_key: None, + base_url: None, + model_capabilities: None, + tool_call_strategy: None, + toolshim_model: None, + }; + + assert_eq!( + resolve_provider_config_apply_mode(&provider_config), + ProviderConfigApplyMode::Direct + ); + } + #[test] fn service_skill_launch_stage_should_preserve_simple_user_message_and_force_site_run_first() { let user_message = "请帮我使用 GitHub 查一下 AI Agent 项目"; diff --git a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs index 68c729cc4..4ce87d62d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs @@ -1,4 +1,7 @@ use super::*; +use crate::agent_tools::catalog::{ + LIME_SITE_INFO_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, +}; use crate::services::site_capability_service::{ get_site_adapter, run_site_adapter_with_optional_save, RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult, @@ -13,6 +16,12 @@ const SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS: &[&str] = &[ "playwright*", ]; +const SERVICE_SKILL_LAUNCH_REPEAT_SITE_TOOL_DENY_PATTERNS: &[&str] = &[ + LIME_SITE_RUN_TOOL_NAME, + LIME_SITE_SEARCH_TOOL_NAME, + LIME_SITE_INFO_TOOL_NAME, +]; + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ServiceSkillLaunchSiteAdapterContext { pub(crate) adapter_name: String, @@ -374,6 +383,10 @@ pub(crate) fn service_skill_launch_browser_deny_patterns() -> &'static [&'static SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS } +pub(crate) fn service_skill_launch_repeat_site_tool_deny_patterns() -> &'static [&'static str] { + SERVICE_SKILL_LAUNCH_REPEAT_SITE_TOOL_DENY_PATTERNS +} + pub(crate) fn build_service_skill_launch_run_request( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -449,4 +462,20 @@ pub(crate) fn append_service_skill_launch_session_permissions( metadata: HashMap::new(), }); } + + for pattern in service_skill_launch_repeat_site_tool_deny_patterns() { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1250, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "站点技能启动回合已完成系统预执行,禁止在同回合重复调用站点执行工具".to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 0896c2294..758fe9a24 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -760,6 +760,18 @@ mod tests { deny_rule.conditions[0].value, serde_json::json!("session-service-skill-1") ); + + let site_run_rule = permissions + .iter() + .find(|permission| permission.tool == "lime_site_run") + .expect("should add site run deny rule"); + assert!(!site_run_rule.allowed); + assert_eq!(site_run_rule.priority, 1250); + assert_eq!(site_run_rule.conditions.len(), 1); + assert_eq!( + site_run_rule.conditions[0].value, + serde_json::json!("session-service-skill-1") + ); } #[test] @@ -5484,6 +5496,11 @@ mod tests { ) .expect("should contain preload prompt"); + assert!(merged.contains("这个 bundle 是系统侧采集得到的源材料")); + assert!(merged.contains("不默认等于用户要的最终交付结果")); + assert!(merged.contains("必须先基于这份已保存的 Markdown 继续完成原任务")); + assert!(merged.contains("不要把保存路径、图片数量或采集摘要原样复述后就停止")); + assert!(merged.contains("不要把 exports 下的源 bundle 直接当成最终结果目录")); assert!(merged.contains("Markdown 正文翻译成中文")); assert!(merged.contains("/tmp/project/saved/x-article-export/index.md")); assert!(merged.contains("只允许新增 Read / Write / Edit")); @@ -6754,7 +6771,7 @@ mod tests { .description() .contains("Fetches full schema definitions")); - super::tool_runtime::search_bridge::register_tool_search_tool_to_registry( + super::tool_runtime::register_tool_search_tool_to_registry( &mut guard, registry.clone(), None, diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index 426906c2e..229e0a98e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -13,7 +13,7 @@ pub(crate) mod media_cli_bridge; #[path = "tool_runtime/resource_search_tools.rs"] mod resource_search_tools; #[path = "tool_runtime/search_bridge.rs"] -mod search_bridge; +pub(crate) mod search_bridge; #[path = "tool_runtime/service_skill_tools.rs"] mod service_skill_tools; #[path = "tool_runtime/site_tools.rs"] @@ -33,6 +33,8 @@ pub(crate) use mcp_resource_tools::ensure_mcp_resource_tools_registered; #[allow(unused_imports)] pub(crate) use mcp_resource_tools::{ListMcpResourcesBridgeTool, ReadMcpResourceBridgeTool}; pub(crate) use search_bridge::ensure_tool_search_tool_registered; +#[cfg(test)] +pub(crate) use search_bridge::register_tool_search_tool_to_registry; #[allow(unused_imports)] pub(crate) use search_bridge::ToolSearchBridgeTool; #[allow(unused_imports)] diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs index 55ef684b3..07259332d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs @@ -373,7 +373,7 @@ impl Tool for ToolSearchBridgeTool { } } -pub(super) fn register_tool_search_tool_to_registry( +pub(crate) fn register_tool_search_tool_to_registry( registry: &mut aster::tools::ToolRegistry, registry_arc: Arc>, extension_manager: Option>, diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 1bcf90039..be0c2e597 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -121,7 +121,7 @@ pub async fn handle_command( return Ok(result); } - if let Some(result) = models::try_handle(state, cmd).await? { + if let Some(result) = models::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } @@ -594,4 +594,21 @@ mod tests { assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); } + + #[tokio::test] + async fn fetch_provider_models_auto_is_bridged() { + let state = make_test_state(); + + let error = handle_command( + &state, + "fetch_provider_models_auto", + Some(serde_json::json!({ + "providerId": "ollama" + })), + ) + .await + .expect_err("missing provider should fail after bridge routing"); + + assert!(error.to_string().contains("Provider 不存在: ollama")); + } } diff --git a/src-tauri/src/dev_bridge/dispatcher/models.rs b/src-tauri/src/dev_bridge/dispatcher/models.rs index b6af514b4..826aef388 100644 --- a/src-tauri/src/dev_bridge/dispatcher/models.rs +++ b/src-tauri/src/dev_bridge/dispatcher/models.rs @@ -1,5 +1,7 @@ +use super::{args_or_default, get_string_arg}; use crate::dev_bridge::DevBridgeState; use lime_server_utils::load_model_registry_provider_ids_from_resources; +use lime_services::model_registry_service::ModelRegistryService; use serde_json::Value as JsonValue; type DynError = Box; @@ -7,6 +9,7 @@ type DynError = Box; pub(super) async fn try_handle( state: &DevBridgeState, cmd: &str, + args: Option<&JsonValue>, ) -> Result, DynError> { let result: JsonValue = match cmd { "get_models" => serde_json::json!({ @@ -56,6 +59,58 @@ pub(super) async fn try_handle( "get_model_registry_provider_ids" => { serde_json::to_value(load_model_registry_provider_ids_from_resources()?)? } + "fetch_provider_models_auto" => { + let args = args_or_default(args); + let provider_id = get_string_arg(&args, "providerId", "provider_id")?; + let db = state + .db + .as_ref() + .ok_or_else(|| "Database not initialized".to_string())?; + let provider = state + .api_key_provider_service + .get_provider(db, &provider_id)? + .ok_or_else(|| format!("Provider 不存在: {provider_id}"))?; + + let api_host = provider.provider.api_host.clone(); + if api_host.is_empty() { + return Err("Provider 没有配置 API Host".into()); + } + + let provider_type = provider.provider.provider_type; + let requires_api_key = ModelRegistryService::requires_api_key_for_model_fetch( + &provider_id, + &api_host, + provider_type, + ); + let api_key = if requires_api_key { + state + .api_key_provider_service + .get_next_api_key(db, &provider_id)? + .ok_or_else(|| format!("Provider {provider_id} 没有可用的 API Key"))? + } else { + state + .api_key_provider_service + .get_next_api_key(db, &provider_id)? + .unwrap_or_default() + }; + + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + + serde_json::to_value( + service + .fetch_models_from_api_with_hints( + &provider_id, + &api_host, + &api_key, + Some(provider_type), + &provider.provider.custom_models, + ) + .await?, + )? + } _ => return Ok(None), }; diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 282002771..1b6ff59c5 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.6.1", + "version": "1.7.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 eebe2f6d0..a426f8613 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.6.1", + "version": "1.7.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 2a2f1ced9..82261f1d7 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -132,6 +132,7 @@ import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntry import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions"; import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary"; import { mergeThreadItems } from "./utils/threadTimelineView"; +import { openCanvasForReason } from "./workspace/canvasOpenPolicy"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; import { asRecord, @@ -188,12 +189,18 @@ import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSess import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime"; import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions"; import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl"; -import { resolveAbsoluteWorkspacePath } from "./workspace/workspacePath"; +import { hasNamedGeneralCanvasFilePreview } from "./workspace/generalCanvasPreviewState"; +import { resolvePreferredServiceSkillResultFileTarget } from "./workspace/serviceSkillResultFileTarget"; +import { + isAbsoluteWorkspacePath, + resolveAbsoluteWorkspacePath, +} from "./workspace/workspacePath"; import { areArtifactProtocolPathsEquivalent, normalizeArtifactProtocolPath, resolveArtifactProtocolFilePath, } from "@/lib/artifact-protocol"; +import { resolveSiteSavedContentTargetFromRunResult } from "./utils/siteToolResultSummary"; import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation"; import { createUnifiedMemory } from "@/lib/api/unifiedMemory"; @@ -624,7 +631,6 @@ export function AgentChatWorkspace({ } | null>(null); const handledInitialPendingServiceSkillLaunchSignatureRef = useRef(""); const handledInitialProjectFileOpenSignatureRef = useRef(""); - const autoOpenedSiteSkillSavedContentSignatureRef = useRef(""); const initialCreationReplay = useMemo( () => extractCreationReplayMetadata(initialRequestMetadata), [initialRequestMetadata], @@ -891,9 +897,18 @@ export function AgentChatWorkspace({ () => resolveDefaultSelectedArtifact(activeTheme, artifacts), [activeTheme, artifacts], ); + const preferGeneralCanvasFilePreview = useMemo( + () => + activeTheme === "general" && + hasNamedGeneralCanvasFilePreview(generalCanvasState), + [activeTheme, generalCanvasState], + ); const liveArtifact = useMemo( - () => selectedArtifact || defaultSelectedArtifact, - [defaultSelectedArtifact, selectedArtifact], + () => + preferGeneralCanvasFilePreview + ? null + : selectedArtifact || defaultSelectedArtifact, + [defaultSelectedArtifact, preferGeneralCanvasFilePreview, selectedArtifact], ); // Artifact 预览状态 @@ -1847,7 +1862,6 @@ export function AgentChatWorkspace({ browserAssistLaunching, browserAssistSessionState, siteSkillExecutionState, - siteSkillSavedContentTarget, currentBrowserAssistScopeKey, ensureBrowserAssistCanvas, suppressBrowserAssistCanvasAutoOpen, @@ -2080,6 +2094,13 @@ export function AgentChatWorkspace({ return; } + if (preferGeneralCanvasFilePreview) { + if (selectedArtifactId !== null) { + setSelectedArtifactId(null); + } + return; + } + if (artifacts.length === 0) { if (selectedArtifactId !== null) { setSelectedArtifactId(null); @@ -2113,6 +2134,7 @@ export function AgentChatWorkspace({ activeTheme, artifacts, defaultSelectedArtifact, + preferGeneralCanvasFilePreview, selectedArtifact, selectedArtifactId, setSelectedArtifactId, @@ -2592,7 +2614,6 @@ export function AgentChatWorkspace({ (await submitImageWorkbenchAgentCommandRef.current?.(params)) ?? false, setCanvasState, setInput, - setLayoutMode, updateCurrentImageWorkbenchState, }); const { handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest } = @@ -3031,6 +3052,7 @@ export function AgentChatWorkspace({ setLayoutMode, setTaskFiles, setSelectedFileId, + setGeneralCanvasState, setCanvasState, }); const handleWorkspaceFileClick = useCallback( @@ -3040,6 +3062,42 @@ export function AgentChatWorkspace({ }, [handleFileClick], ); + const openProjectFilePreviewInCanvas = useCallback( + async ({ + relativePath, + absolutePath, + isCancelled, + }: { + relativePath?: string | null; + absolutePath: string; + isCancelled?: () => boolean; + }) => { + const preview = await handleHarnessLoadFilePreview(absolutePath); + if (isCancelled?.()) { + return false; + } + + if (preview.error) { + toast.error(`打开导出文件失败: ${preview.error}`); + return false; + } + + if (preview.isBinary) { + toast.info("导出文件是二进制格式,暂不支持在工作台预览"); + return false; + } + + const nextContent = + typeof preview.content === "string" ? preview.content : ""; + const nextFilePath = + relativePath?.trim() || preview.path || absolutePath; + startTransition(() => { + handleWorkspaceFileClick(nextFilePath, nextContent); + }); + return true; + }, + [handleHarnessLoadFilePreview, handleWorkspaceFileClick], + ); const handleOpenSavedSiteContent = useCallback( async ({ projectId: targetProjectId, @@ -3061,23 +3119,13 @@ export function AgentChatWorkspace({ relativePath, ); if (absolutePath) { - const preview = await handleHarnessLoadFilePreview(absolutePath); - if (preview.error) { - toast.error(`打开导出文件失败: ${preview.error}`); - return; - } - - if (preview.isBinary) { - toast.info("导出文件是二进制格式,暂不支持在工作台预览"); - return; - } - - const nextContent = - typeof preview.content === "string" ? preview.content : ""; - startTransition(() => { - handleWorkspaceFileClick(preview.path || absolutePath, nextContent); + const opened = await openProjectFilePreviewInCanvas({ + relativePath, + absolutePath, }); - return; + if (opened) { + return; + } } } @@ -3098,58 +3146,11 @@ export function AgentChatWorkspace({ }, [ _onNavigate, - handleHarnessLoadFilePreview, - handleWorkspaceFileClick, + openProjectFilePreviewInCanvas, project?.rootPath, projectId, ], ); - useEffect(() => { - if (siteSkillExecutionState?.phase === "success") { - return; - } - autoOpenedSiteSkillSavedContentSignatureRef.current = ""; - }, [siteSkillExecutionState?.phase]); - useEffect(() => { - if (siteSkillExecutionState?.phase !== "success") { - return; - } - - if (siteSkillSavedContentTarget?.preferredTarget !== "project_file") { - return; - } - - if (!projectId || siteSkillSavedContentTarget.projectId !== projectId) { - return; - } - - const relativePath = - siteSkillSavedContentTarget.projectFile?.relativePath?.trim() || ""; - if (!relativePath) { - return; - } - - const signature = JSON.stringify({ - adapterName: siteSkillExecutionState.adapterName, - sourceUrl: siteSkillExecutionState.sourceUrl?.trim() || "", - projectId: siteSkillSavedContentTarget.projectId, - contentId: siteSkillSavedContentTarget.contentId, - relativePath, - }); - if (autoOpenedSiteSkillSavedContentSignatureRef.current === signature) { - return; - } - autoOpenedSiteSkillSavedContentSignatureRef.current = signature; - - void handleOpenSavedSiteContent(siteSkillSavedContentTarget); - }, [ - handleOpenSavedSiteContent, - projectId, - siteSkillExecutionState?.adapterName, - siteSkillExecutionState?.phase, - siteSkillExecutionState?.sourceUrl, - siteSkillSavedContentTarget, - ]); const handleWorkspaceArtifactClick = useCallback( (artifact: Artifact) => { setFocusedArtifactBlockId(null); @@ -3169,7 +3170,7 @@ export function AgentChatWorkspace({ active: true, }; }); - setLayoutMode("chat-canvas"); + openCanvasForReason("user_open_message_preview", setLayoutMode); return; } @@ -3191,7 +3192,7 @@ export function AgentChatWorkspace({ ? preview.statusMessage?.trim() || "视频任务未成功完成" : undefined, }); - setLayoutMode("chat-canvas"); + openCanvasForReason("user_open_message_preview", setLayoutMode); return; } @@ -3247,6 +3248,72 @@ export function AgentChatWorkspace({ }, [handleWorkspaceFileClick], ); + const siteSkillSavedContentTarget = useMemo( + () => + resolveSiteSavedContentTargetFromRunResult( + siteSkillExecutionState?.result || null, + ), + [siteSkillExecutionState?.result], + ); + const currentTurnThreadItems = useMemo( + () => + currentTurnId + ? effectiveThreadItems.filter((item) => item.turn_id === currentTurnId) + : [], + [currentTurnId, effectiveThreadItems], + ); + const preferredServiceSkillResultFileTarget = useMemo( + () => + resolvePreferredServiceSkillResultFileTarget({ + threadItems: currentTurnThreadItems, + savedContentTarget: siteSkillSavedContentTarget, + }), + [currentTurnThreadItems, siteSkillSavedContentTarget], + ); + const handleOpenServiceSkillResultFile = useCallback( + async (relativePath: string) => { + const normalizedPath = relativePath.trim(); + if (!normalizedPath) { + return; + } + + const absolutePath = resolveAbsoluteWorkspacePath( + project?.rootPath, + normalizedPath, + ); + if (absolutePath) { + const opened = await openProjectFilePreviewInCanvas({ + relativePath: normalizedPath, + absolutePath, + }); + if (opened) { + return; + } + } + + const matchedTaskFile = taskFiles.find( + (file) => + areArtifactProtocolPathsEquivalent(file.name, normalizedPath) || + normalizeArtifactProtocolPath(file.name) === + normalizeArtifactProtocolPath(normalizedPath), + ); + if (matchedTaskFile) { + handleWorkspaceFileClick( + matchedTaskFile.name, + matchedTaskFile.content ?? "", + ); + return; + } + + toast.error("打开结果文件失败:当前工作区里还没有同步到这份文件"); + }, + [ + handleWorkspaceFileClick, + openProjectFilePreviewInCanvas, + project?.rootPath, + taskFiles, + ], + ); useEffect(() => { const relativePath = initialProjectFileOpenTarget?.relativePath?.trim(); if (!relativePath) { @@ -3258,6 +3325,10 @@ export function AgentChatWorkspace({ return; } + if (!project?.rootPath && !isAbsoluteWorkspacePath(relativePath)) { + return; + } + const absolutePath = resolveAbsoluteWorkspacePath(project?.rootPath, relativePath); if (!absolutePath) { return; @@ -3276,24 +3347,10 @@ export function AgentChatWorkspace({ let cancelled = false; void (async () => { - const preview = await handleHarnessLoadFilePreview(absolutePath); - if (cancelled) { - return; - } - - if (preview.error) { - toast.error(`打开导出文件失败: ${preview.error}`); - return; - } - - if (preview.isBinary) { - toast.info("导出文件是二进制格式,暂不支持在工作台预览"); - return; - } - - const nextContent = typeof preview.content === "string" ? preview.content : ""; - startTransition(() => { - handleWorkspaceFileClick(preview.path || absolutePath, nextContent); + await openProjectFilePreviewInCanvas({ + relativePath, + absolutePath, + isCancelled: () => cancelled, }); })(); @@ -3302,10 +3359,9 @@ export function AgentChatWorkspace({ }; }, [ contentId, - handleHarnessLoadFilePreview, - handleWorkspaceFileClick, initialProjectFileOpenTarget, isInitialContentLoading, + openProjectFilePreviewInCanvas, project?.rootPath, projectId, ]); @@ -3318,12 +3374,16 @@ export function AgentChatWorkspace({ ? handleOpenBrowserRuntimeForSiteSkillExecution : undefined } + preferredResultFileTarget={preferredServiceSkillResultFileTarget} + onOpenResultFile={handleOpenServiceSkillResultFile} onOpenSavedSiteContent={handleOpenSavedSiteContent} /> ), [ + handleOpenServiceSkillResultFile, handleOpenBrowserRuntimeForSiteSkillExecution, handleOpenSavedSiteContent, + preferredServiceSkillResultFileTarget, siteSkillExecutionState, ], ); diff --git a/src/components/agent/chat/README.md b/src/components/agent/chat/README.md index af2359d08..42797af0f 100644 --- a/src/components/agent/chat/README.md +++ b/src/components/agent/chat/README.md @@ -17,20 +17,21 @@ AI Agent 聊天页面,承接新建任务与任务中心两种入口。当前 ### components/ -| 文件 | 说明 | -| --------------------------- | ---------------------------------------------------------------------------- | -| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) | -| `ChatSidebar.tsx` | 侧边栏(任务列表) | -| `MessageList.tsx` | 消息列表组件 | -| `Inputbar.tsx` | 输入栏组件 | -| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) | -| `CanvasWorkbenchLayout.tsx` | 画布响应式工作台,宽屏侧栏与窄屏底部面板均支持产物、文件树、变更、预览与下载 | +| 文件 | 说明 | +| -------------------------------- | ------------------------------------------------------------------------------------ | +| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) | +| `ChatSidebar.tsx` | 侧边栏(任务列表) | +| `MessageList.tsx` | 消息列表组件 | +| `Inputbar.tsx` | 输入栏组件 | +| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) | +| `CanvasWorkbenchLayout.tsx` | 画布顶部标签工作台,统一承载 `Session · Main`、`workspace` 与动态文件标签 | +| `CanvasSessionOverviewPanel.tsx` | 会话过程面板,展示 turn、skills / tools、A2UI 与排队状态的统一过程视图 | ### hooks/ -| 文件 | 说明 | -| ---------------------- | ---------------------------------------------------------------- | -| `useAsterAgentChat.ts` | 现役 Aster 聊天主 Hook | +| 文件 | 说明 | +| ---------------------- | -------------------------------------------------------------------- | +| `useAsterAgentChat.ts` | 现役 Aster 聊天主 Hook | | `index.ts` | `useAgentChatUnified` 统一入口与 Hook 导出,旧 `useAgentChat` 已删除 | ## 核心功能 @@ -50,9 +51,12 @@ AI Agent 聊天页面,承接新建任务与任务中心两种入口。当前 ### 3. 画布内工作台 -- 宽屏使用右侧内嵌工作台,窄屏自动切换为底部工作台,均支持 `产物 / 全部文件 / 变更 / 预览` -- 支持画布面板折叠/展开 -- 支持复制路径、系统打开、定位与文本下载 +- 使用单一大画布容器 + 顶部标签导航,避免右侧工作台与正文分裂成两套心智 +- 固定主标签为 `Session · Main`、`workspace`,并按真实文件动态追加文件标签 +- 顶部改为紧凑标签栏,保留文件操作,删除大块摘要头,避免把说明文案继续堆在画布最上方 +- 当存在自定义 `sessionView` 时,初始化默认落在 `Session · Main`,不再被默认主稿文件标签抢焦点;若已存在主稿,则面板优先打开主稿预览而不是重复显示过程总览 +- `CanvasWorkbenchLayout` 仅负责壳层与标签切换,会话过程、顶部主标签语义、工作区摘要与 Team 摘要都通过 `sessionView` / `workspaceView` / `teamView` 插槽注入,避免把 slash / skill / team 逻辑硬编码进画布壳 +- 文件标签内支持正文 / 变更切换、复制路径、系统打开、定位与文本下载 ## 依赖模块 diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 49fd006e9..a389b6ba7 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -260,7 +260,7 @@ function createFileArtifactItem( return { ...createBaseItem("artifact-1", 1), type: "file_artifact", - path: ".lime/artifacts/thread-1/demo.artifact.json", + path: "exports/x-article-export/google/index.md", source: "artifact_snapshot", content: JSON.stringify({ schemaVersion: "artifact_document.v1", @@ -374,12 +374,40 @@ describe("AgentThreadTimeline", () => { expect(onOpenArtifactFromTimeline).toHaveBeenCalledWith( expect.objectContaining({ timelineItemId: "artifact-1", - filePath: ".lime/artifacts/thread-1/demo.artifact.json", + filePath: "exports/x-article-export/google/index.md", blockId: "hero-1", }), ); }); + it("多个 file_artifact 应直接渲染卡片,不再重复显示产出摘要头", () => { + const container = renderTimeline([ + createFileArtifactItem({ + path: "workspace/index.md", + content: "# Index\n\n主文档内容", + }), + createFileArtifactItem({ + ...createBaseItem("artifact-2", 2), + path: "workspace/Agents.md", + content: "# Agents\n\n协作说明", + }), + ]); + + expect( + container.querySelector('[data-testid="agent-thread-block:1:artifact"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="agent-thread-block:1:artifact:shell"]', + ), + ).toBeNull(); + expect( + container.querySelectorAll('[data-testid="timeline-file-artifact-card"]'), + ).toHaveLength(2); + expect(container.textContent).not.toContain("产出了 index.md"); + expect(container.textContent).not.toContain("产出了 Agents.md"); + }); + it("不应把 .lime/tasks 下的内部任务快照 JSON 渲染到时间线里", () => { const container = renderTimeline([ createFileArtifactItem({ diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 4a27f963e..1893069da 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -27,7 +27,7 @@ import { import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI"; import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder"; -import { isHiddenInternalArtifactPath } from "../utils/internalArtifactVisibility"; +import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility"; import { parseAIResponse } from "@/lib/workspace/a2ui"; import type { A2UIResponse } from "@/lib/workspace/a2ui"; import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui"; @@ -815,7 +815,7 @@ function renderGroupItemDetails( } if (item.type === "file_artifact") { - if (isHiddenInternalArtifactPath(item.path)) { + if (isHiddenConversationArtifactPath(item.path)) { return null; } @@ -1382,6 +1382,35 @@ function TimelineBlockCard({ }); }, [focusRequestKey, hasFocusedItem]); + const shouldRenderArtifactCardsInline = + block.kind === "artifact" && + hasDetailEntries && + block.items.every((item) => item.type === "file_artifact"); + + if (shouldRenderArtifactCardsInline) { + return ( +
+ {detailEntries.map((entry) => ( +
+ {entry.content} +
+ ))} +
+ ); + } + const singleItemContent = block.items.length === 1 && !(isThinkingOnlyBlock && block.status === "completed") @@ -1567,7 +1596,7 @@ export const AgentThreadTimeline: React.FC = ({ item.type !== "agent_message" && !( item.type === "file_artifact" && - isHiddenInternalArtifactPath(item.path) + isHiddenConversationArtifactPath(item.path) ), ), [items], diff --git a/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx b/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx new file mode 100644 index 000000000..827f27cd0 --- /dev/null +++ b/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx @@ -0,0 +1,589 @@ +import { + AlertTriangle, + Bot, + Clock3, + FileText, + ListChecks, + Loader2, + Search, + ShieldAlert, + Sparkles, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, +} from "../types"; +import { sortThreadItems } from "../utils/threadTimelineView"; +import { extractFileNameFromPath } from "../workspace/workspacePath"; + +interface CanvasSessionOverviewPanelProps { + turns: AgentThreadTurn[]; + threadItems: AgentThreadItem[]; + currentTurnId?: string | null; + pendingActions?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + isSending?: boolean; + focusedItemId?: string | null; +} + +type SessionStatusTone = "default" | "accent" | "success"; + +interface SessionActivityView { + id: string; + title: string; + summary: string; + timeLabel: string | null; + statusLabel: string; + tone: SessionStatusTone; + icon: typeof Sparkles; + iconClassName: string; +} + +function shortenText(value?: string | null, maxLength = 120): string { + const normalized = (value || "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function formatTimeLabel(value?: string | null): string | null { + if (!value) { + return null; + } + + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + return timestamp.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); +} + +function resolveTurnStatusLabel(status?: AgentThreadTurn["status"] | null): { + label: string; + tone: SessionStatusTone; +} { + if (status === "running") { + return { label: "执行中", tone: "accent" }; + } + if (status === "completed") { + return { label: "已完成", tone: "success" }; + } + if (status === "failed") { + return { label: "失败", tone: "default" }; + } + if (status === "aborted") { + return { label: "已中断", tone: "default" }; + } + return { label: "空闲", tone: "default" }; +} + +function resolveItemStatusLabel(item: AgentThreadItem): { + label: string; + tone: SessionStatusTone; +} { + if (item.status === "in_progress") { + return { label: "进行中", tone: "accent" }; + } + if (item.status === "failed") { + return { label: "失败", tone: "default" }; + } + return { label: "已完成", tone: "success" }; +} + +function resolvePendingActionPreview(action: ActionRequired): string { + const prompt = shortenText(action.prompt, 120); + if (prompt) { + return prompt; + } + + const firstQuestion = action.questions?.[0]; + const questionText = shortenText(firstQuestion?.question, 120); + if (questionText) { + return questionText; + } + + if (action.toolName?.trim()) { + return action.toolName.trim(); + } + + return action.requestId; +} + +function buildActivityView(item: AgentThreadItem): SessionActivityView | null { + const { label: statusLabel, tone } = resolveItemStatusLabel(item); + const timeLabel = formatTimeLabel( + item.updated_at || item.completed_at || item.started_at, + ); + + switch (item.type) { + case "tool_call": + return { + id: item.id, + title: item.tool_name || "工具调用", + summary: + shortenText(item.error, 100) || + shortenText( + typeof item.arguments === "string" + ? item.arguments + : JSON.stringify(item.arguments ?? {}, null, 2), + 100, + ) || + "工具已接入当前运行轨迹。", + timeLabel, + statusLabel, + tone, + icon: Sparkles, + iconClassName: "text-sky-600", + }; + case "command_execution": + return { + id: item.id, + title: "exec_command", + summary: shortenText(item.command, 100) || "命令执行中", + timeLabel, + statusLabel, + tone, + icon: ListChecks, + iconClassName: "text-slate-600", + }; + case "web_search": + return { + id: item.id, + title: item.action?.trim() || "Web Search", + summary: + shortenText(item.query, 100) || + shortenText(item.output, 100) || + "正在检索外部信息。", + timeLabel, + statusLabel, + tone, + icon: Search, + iconClassName: "text-sky-600", + }; + case "request_user_input": + return { + id: item.id, + title: "等待补充信息", + summary: + shortenText(item.prompt, 100) || + shortenText(item.questions?.[0]?.question, 100) || + "A2UI / 问答采集中", + timeLabel, + statusLabel, + tone, + icon: ShieldAlert, + iconClassName: "text-amber-600", + }; + case "approval_request": + return { + id: item.id, + title: "等待确认", + summary: + shortenText(item.prompt, 100) || + shortenText(item.tool_name, 100) || + "需要用户确认后继续。", + timeLabel, + statusLabel, + tone, + icon: ShieldAlert, + iconClassName: "text-amber-600", + }; + case "file_artifact": + return { + id: item.id, + title: "产物已写入", + summary: + shortenText(extractFileNameFromPath(item.path) || item.path, 100) || + "已产生新的文件产物。", + timeLabel, + statusLabel, + tone, + icon: FileText, + iconClassName: "text-emerald-600", + }; + case "subagent_activity": + return { + id: item.id, + title: item.title?.trim() || "子 Agent 活动", + summary: + shortenText(item.summary, 100) || + shortenText(item.role, 100) || + shortenText(item.model, 100) || + "协作成员正在推进任务。", + timeLabel, + statusLabel, + tone, + icon: Bot, + iconClassName: "text-sky-700", + }; + case "warning": + return { + id: item.id, + title: "运行警告", + summary: shortenText(item.message, 100) || "运行过程中出现警告。", + timeLabel, + statusLabel, + tone: "default", + icon: AlertTriangle, + iconClassName: "text-amber-600", + }; + case "error": + return { + id: item.id, + title: "执行失败", + summary: shortenText(item.message, 100) || "当前回合执行失败。", + timeLabel, + statusLabel, + tone: "default", + icon: AlertTriangle, + iconClassName: "text-rose-600", + }; + case "context_compaction": + return { + id: item.id, + title: item.stage === "started" ? "上下文压缩中" : "上下文压缩完成", + summary: + shortenText(item.detail, 100) || + shortenText(item.trigger, 100) || + "会话上下文正在整理。", + timeLabel, + statusLabel, + tone, + icon: Clock3, + iconClassName: "text-slate-500", + }; + case "reasoning": + return { + id: item.id, + title: "思考过程", + summary: + shortenText(item.summary?.join(" "), 100) || + shortenText(item.text, 100) || + "模型正在整理思路。", + timeLabel, + statusLabel, + tone, + icon: Sparkles, + iconClassName: "text-violet-600", + }; + case "plan": + return { + id: item.id, + title: "执行计划", + summary: shortenText(item.text, 100) || "已生成执行计划。", + timeLabel, + statusLabel, + tone, + icon: ListChecks, + iconClassName: "text-slate-600", + }; + case "turn_summary": + return { + id: item.id, + title: "回合总结", + summary: shortenText(item.text, 100) || "当前回合已生成总结。", + timeLabel, + statusLabel, + tone, + icon: ListChecks, + iconClassName: "text-slate-600", + }; + default: + return null; + } +} + +export function CanvasSessionOverviewPanel({ + turns, + threadItems, + currentTurnId = null, + pendingActions = [], + queuedTurns = [], + isSending = false, + focusedItemId = null, +}: CanvasSessionOverviewPanelProps) { + const sortedItems = sortThreadItems(threadItems).filter( + (item) => item.type !== "user_message" && item.type !== "agent_message", + ); + + const currentTurn = + turns.find((turn) => turn.id === currentTurnId) || turns.at(-1) || null; + const turnStatus = resolveTurnStatusLabel( + isSending ? "running" : currentTurn?.status, + ); + const inProgressCount = sortedItems.filter( + (item) => item.status === "in_progress", + ).length; + const recentActivity = sortedItems + .map((item) => buildActivityView(item)) + .filter((item): item is SessionActivityView => Boolean(item)) + .slice(-8) + .reverse(); + const latestTurnPrompt = + shortenText(currentTurn?.prompt_text, 160) || "当前还没有新的运行输入。"; + const latestTurnUpdatedAt = formatTimeLabel( + currentTurn?.updated_at || + currentTurn?.completed_at || + currentTurn?.started_at, + ); + const focusedActivity = + recentActivity.find((item) => item.id === focusedItemId) || + recentActivity[0] || + null; + + return ( +
+
+
+
+
+
+ 会话过程索引 +
+ + {turnStatus.label} + + {focusedActivity ? ( + + 聚焦 {focusedActivity.title} + + ) : null} +
+

+ 这里只补充当前回合的执行过程、待补信息和排队状态。主稿、Markdown + 与图片请直接从对话里的“在画布中打开”入口进入。 +

+
+ + 当前 turn:{currentTurn?.id || "尚未创建"} + + + {inProgressCount > 0 + ? `进行中 ${inProgressCount}` + : `轨迹 ${sortedItems.length}`} + + + {pendingActions.length > 0 + ? `待补信息 ${pendingActions.length}` + : queuedTurns.length > 0 + ? `排队 ${queuedTurns.length}` + : "无需跟进"} + +
+
+ +
+
+
+ 最近更新时间 +
+
+ {latestTurnUpdatedAt || "--:--"} +
+
+
+
+ 当前焦点 +
+
+ {focusedActivity?.title || "等待新的执行事件"} +
+
+ {focusedActivity?.summary || latestTurnPrompt} +
+
+
+
+
+ +
+
+
+
+ 执行时间线 +
+
+ 这里展示当前会话的技能、工具、A2UI、文件产物与异常信号,方便直接判断下一步卡在哪里。 +
+
+ +
+ {recentActivity.length > 0 ? ( + recentActivity.map((item) => { + const Icon = item.icon; + return ( +
+
+ {item.tone === "accent" ? ( + + ) : ( + + )} +
+
+
+
+ {item.title} +
+ + {item.statusLabel} + +
+
+ {item.summary} +
+
+
+ {item.timeLabel || "--:--"} +
+
+ ); + }) + ) : ( +
+ 当前还没有可展示的执行轨迹。新的工具调用、skills、A2UI + 与排队变化会出现在这里。 +
+ )} +
+
+ +
+
+
+
+ 待处理交互 +
+
+ 缺少信息时,这里会明确显示等待补充的信息入口,便于与 A2UI + 收集流程对齐。 +
+
+ +
+ {pendingActions.length > 0 ? ( + pendingActions.slice(0, 4).map((action) => ( +
+
+ + {action.actionType === "tool_confirmation" + ? "等待确认" + : "等待补充信息"} + + + {action.requestId} + +
+
+ {resolvePendingActionPreview(action)} +
+
+ )) + ) : ( +
+ 当前没有等待中的确认或补充信息请求。 +
+ )} +
+
+ +
+
+
+ 排队消息 +
+
+ 多轮追问或连续提交时,这里保留稍后执行的输入快照。 +
+
+ +
+ {queuedTurns.length > 0 ? ( + queuedTurns.slice(0, 4).map((item, index) => ( +
+
+ + 排队 {index + 1} + + + {item.image_count > 0 + ? `${item.image_count} 张图` + : "文本输入"} + +
+
+ {shortenText( + item.message_preview || item.message_text, + 120, + )} +
+
+ )) + ) : ( +
+ 当前没有排队中的消息。 +
+ )} +
+
+
+
+
+ ); +} + +export default CanvasSessionOverviewPanel; diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index a14197f1c..b644fdd2d 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -4,7 +4,6 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { Artifact } from "@/lib/artifact/types"; -import { emitCompactRightPanelOpen } from "@/lib/compactRightPanelEvents"; import type { TaskFile } from "./TaskFiles"; import { CanvasWorkbenchLayout, @@ -265,12 +264,6 @@ function MockArtifactDocumentPreview({ ); } -function mount( - props: React.ComponentProps, -): HTMLDivElement { - return mountHarness(props).container; -} - function mountHarness( props: React.ComponentProps, ): MountedHarness { @@ -298,6 +291,12 @@ function mountHarness( return harness; } +function mount( + props: React.ComponentProps, +): HTMLDivElement { + return mountHarness(props).container; +} + async function flushEffects(times = 6) { for (let i = 0; i < times; i += 1) { await act(async () => { @@ -330,16 +329,16 @@ async function resizeWorkbench(width: number, height = 720) { }); } -function clickButtonByLabel(container: HTMLElement, ariaLabel: string) { - const button = container.querySelector( - `button[aria-label="${ariaLabel}"]`, - ) as HTMLButtonElement | null; - if (!button) { - throw new Error(`未找到按钮: ${ariaLabel}`); +function clickByAriaLabel(container: HTMLElement, ariaLabel: string) { + const element = container.querySelector( + `[aria-label="${ariaLabel}"]`, + ) as HTMLElement | null; + if (!element) { + throw new Error(`未找到元素: ${ariaLabel}`); } act(() => { - button.click(); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); } @@ -403,6 +402,41 @@ beforeEach(() => { parentPath: null, error: null, entries: [ + { + name: ".lime", + path: "/workspace/.lime", + isDir: true, + size: 0, + modifiedAt: 100, + }, + { + name: "exports", + path: "/workspace/exports", + isDir: true, + size: 0, + modifiedAt: 100, + }, + { + name: "output", + path: "/workspace/output", + isDir: true, + size: 0, + modifiedAt: 100, + }, + { + name: ".DS_Store", + path: "/workspace/.DS_Store", + isDir: false, + size: 10, + modifiedAt: 100, + }, + { + name: "output_image.jpg", + path: "/workspace/output_image.jpg", + isDir: false, + size: 512, + modifiedAt: 100, + }, { name: "README.md", path: "/workspace/README.md", @@ -438,6 +472,51 @@ beforeEach(() => { }; } + if (path === "/workspace/exports/x-article-export/latest") { + return { + path, + parentPath: "/workspace/exports/x-article-export", + error: null, + entries: [ + { + name: "manifest.json", + path: "/workspace/exports/x-article-export/latest/manifest.json", + isDir: false, + size: 1024, + modifiedAt: 100, + }, + { + name: "images", + path: "/workspace/exports/x-article-export/latest/images", + isDir: true, + size: 0, + modifiedAt: 100, + }, + { + name: "skills", + path: "/workspace/exports/x-article-export/latest/skills", + isDir: true, + size: 0, + modifiedAt: 100, + }, + { + name: "Agents.md", + path: "/workspace/exports/x-article-export/latest/Agents.md", + isDir: false, + size: 256, + modifiedAt: 100, + }, + { + name: "index.md", + path: "/workspace/exports/x-article-export/latest/index.md", + isDir: false, + size: 2048, + modifiedAt: 100, + }, + ], + }; + } + return { path, parentPath: "/workspace", @@ -479,8 +558,7 @@ afterEach(() => { }); describe("CanvasWorkbenchLayout", () => { - it("应支持默认画布、标签切换、文件树、diff 与下载动作", async () => { - const previewTargets: CanvasWorkbenchPreviewTarget[] = []; + it("应以顶部标签式画布承载 session、文件与结果文件标签", async () => { const onOpenPath = vi.fn(async () => undefined); const onRevealPath = vi.fn(async () => undefined); const loadFilePreview = vi.fn(async (path: string) => { @@ -526,79 +604,147 @@ describe("CanvasWorkbenchLayout", () => { loadFilePreview, onOpenPath, onRevealPath, - renderPreview: (target, options) => { - previewTargets.push(target); - return ( -
- {options?.stackedWorkbenchTrigger} - {target.kind}:{target.title} -
- ); - }, + renderPreview: (target) => ( +
+ {target.kind}:{target.title} +
+ ), }); await flushEffects(); expect(mockListDirectory).toHaveBeenCalledWith("/workspace"); + expect( + container + .querySelector('[data-testid="canvas-workbench-shell"]') + ?.getAttribute("data-layout-mode"), + ).toBe("split"); + expect( + container.querySelector( + 'button[aria-label="切换画布标签-Session · Main"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="切换画布标签-文件"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="切换画布标签-outputs"]'), + ).toBeNull(); + expect( + container.querySelector('button[aria-label="切换画布标签-draft.md"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="preview-panel"]')?.textContent, ).toContain("default-canvas:draft.md"); - clickButtonByLabel(container, "切换画布标签-变更"); + clickByAriaLabel(container, "切换画布标签-文件"); await flushEffects(); - expect(container.textContent).toContain("上一版本"); - expect(container.textContent).toContain("当前画布正文"); - clickButtonByLabel(container, "切换画布标签-预览"); - expect(container.textContent).toContain("当前画布正文"); - - clickButtonByLabel(container, "折叠画布工作台"); - expect( - container.querySelector('button[aria-label="展开画布工作台"]'), - ).not.toBeNull(); - clickButtonByLabel(container, "展开画布工作台"); - - clickButtonByLabel(container, "切换画布标签-产物"); - clickButtonByLabel(container, "选择画布产物-draft.md"); - await flushEffects(); - expect( - container.querySelector('[data-testid="preview-panel"]')?.textContent, - ).toContain("artifact:draft.md"); - - clickButtonByLabel(container, "切换画布标签-全部文件"); - await flushEffects(); - clickButtonByLabel(container, "选择工作区文件-README.md"); + expect(container.textContent).not.toContain(".lime"); + expect(container.textContent).toContain("exports"); + expect(container.textContent).not.toContain("output_image.jpg"); + expect(container.textContent).not.toContain(".DS_Store"); + clickByAriaLabel(container, "选择工作区文件-README.md"); await flushEffects(); expect(loadFilePreview).toHaveBeenCalledWith("/workspace/README.md"); - expect(previewTargets.at(-1)?.kind).toBe("synthetic-artifact"); + expect( + container.querySelector('[data-testid="preview-panel"]')?.textContent, + ).toContain("default-canvas:README.md"); + expect( + container.querySelector('button[aria-label="切换画布标签-README.md"]'), + ).not.toBeNull(); - clickButtonByLabel(container, "复制当前路径"); + clickByAriaLabel(container, "复制当前路径"); await flushEffects(); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( "/workspace/README.md", ); - clickButtonByLabel(container, "定位当前文件"); + clickByAriaLabel(container, "定位当前文件"); await flushEffects(); expect(onRevealPath).toHaveBeenCalledWith("/workspace/README.md"); - clickButtonByLabel(container, "系统打开当前文件"); + clickByAriaLabel(container, "系统打开当前文件"); await flushEffects(); expect(onOpenPath).toHaveBeenCalledWith("/workspace/README.md"); - clickButtonByLabel(container, "下载当前画布项"); + clickByAriaLabel(container, "下载当前画布项"); expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1); expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1); + + clickByAriaLabel(container, "关闭文件标签-README.md"); + await flushEffects(); + expect( + container.querySelector('button[aria-label="切换画布标签-README.md"]'), + ).toBeNull(); }); - it("命中文档产物时应把文稿 inspector 收口到右侧工作台", async () => { + it("命中导出结果文件时应将文件树聚焦到当前结果目录", async () => { + const container = mount({ + artifacts: [], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: { + selectionKey: "default-preview:exports/x-article-export/latest/index.md", + title: "index.md", + content: "# 导出结果\n\n这是正文。", + filePath: "exports/x-article-export/latest/index.md", + absolutePath: "/workspace/exports/x-article-export/latest/index.md", + previousContent: null, + }, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "# 文件内容", + isBinary: false, + size: 128, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target) => ( +
+ {target.kind}:{target.title} +
+ ), + }); + + await flushEffects(); + clickByAriaLabel(container, "切换画布标签-文件"); + await flushEffects(); + + expect(mockListDirectory).toHaveBeenCalledWith( + "/workspace/exports/x-article-export/latest", + ); + expect(container.textContent).toContain("结果目录"); + expect(container.textContent).toContain("exports/x-article-export/latest"); + + const workspaceButtons = Array.from( + container.querySelectorAll( + '[data-testid="canvas-workbench-panel-workspace"] button[aria-label]', + ), + ).map((element) => element.getAttribute("aria-label")); + + expect(workspaceButtons).toContain("选择工作区文件-index.md"); + expect(workspaceButtons).toContain("选择工作区文件-Agents.md"); + expect(workspaceButtons).toContain("展开目录-skills"); + expect(workspaceButtons).toContain("展开目录-images"); + + const indexButtonPosition = workspaceButtons.indexOf("选择工作区文件-index.md"); + const agentsButtonPosition = + workspaceButtons.indexOf("选择工作区文件-Agents.md"); + const manifestButtonPosition = + workspaceButtons.indexOf("选择工作区文件-manifest.json"); + + expect(indexButtonPosition).toBeGreaterThanOrEqual(0); + expect(agentsButtonPosition).toBeGreaterThan(indexButtonPosition); + expect(manifestButtonPosition).toBeGreaterThan(agentsButtonPosition); + }); + + it("命中文档产物时应在文件标签内提供文稿 inspector", async () => { const controller = createMockArtifactDocumentController(); - const previewOptions: Array<{ - onArtifactDocumentControllerChange?: ( - value: ArtifactWorkbenchDocumentController | null, - ) => void; - }> = []; const container = mount({ artifacts: [controller.artifact], @@ -616,21 +762,15 @@ describe("CanvasWorkbenchLayout", () => { })), onOpenPath: vi.fn(async () => undefined), onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => { - previewOptions.push({ - onArtifactDocumentControllerChange: - options?.onArtifactDocumentControllerChange, - }); - return ( - - ); - }, + renderPreview: (target, options) => ( + + ), }); await flushEffects(); @@ -638,26 +778,16 @@ describe("CanvasWorkbenchLayout", () => { expect( container.querySelector('[data-testid="preview-panel"]')?.textContent, ).toContain("artifact:board-review.artifact.json"); + expect(container.textContent).toContain("当前文稿"); + expect(container.textContent).toContain("董事会季度复盘"); + expect(container.textContent).toContain("需要优先补齐来源与版本线索。"); expect( container.querySelector('button[aria-label="展开当前文稿检查器"]'), ).not.toBeNull(); - expect( - container.querySelector( - '[data-testid="canvas-workbench-document-inspector"]', - ), - ).toBeNull(); - expect(container.textContent).toContain("当前文稿"); - expect(container.textContent).toContain("统一在右侧切换产物与版本"); - expect(container.textContent).toContain("董事会季度复盘"); - expect(container.textContent).toContain("需要优先补齐来源与版本线索。"); - expect(container.textContent).toContain("默认先收起概览、来源、版本与编辑"); - clickButtonByLabel(container, "展开当前文稿检查器"); + clickByAriaLabel(container, "展开当前文稿检查器"); await flushEffects(); - expect( - container.querySelector('button[aria-label="折叠当前文稿检查器"]'), - ).not.toBeNull(); expect( container.querySelector( '[data-testid="canvas-workbench-document-inspector"]', @@ -665,7 +795,7 @@ describe("CanvasWorkbenchLayout", () => { ).not.toBeNull(); }); - it("内容发布主链产物应在右侧工作台列表显示语义标题", async () => { + it("内容发布主链输出应直接打开真实文件标签,同时预览保留语义标题", async () => { const artifact = createArtifact( "artifact-content-preview", "content-posts/demo-preview.md", @@ -695,9 +825,8 @@ describe("CanvasWorkbenchLayout", () => { })), onOpenPath: vi.fn(async () => undefined), onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => ( + renderPreview: (target) => (
- {options?.stackedWorkbenchTrigger} {target.kind}:{target.title}
), @@ -705,66 +834,351 @@ describe("CanvasWorkbenchLayout", () => { await flushEffects(); - expect(container.textContent).toContain("渠道预览稿"); - expect(container.textContent).toContain("content-posts/demo-preview.md"); - - clickButtonByLabel(container, "选择画布产物-渠道预览稿"); - await flushEffects(); - expect( container.querySelector('[data-testid="preview-panel"]')?.textContent, ).toContain("artifact:渠道预览稿"); + expect( + container.querySelector( + 'button[aria-label="切换画布标签-demo-preview.md"]', + ), + ).not.toBeNull(); }); - it("恢复后的内容发布任务文件也应在右侧工作台列表显示语义标题", async () => { - const taskFile = createTaskFile( - "task-content-preview", - "content-posts/restored-preview.md", - "# 春日咖啡活动\n\n首屏预览", - 60, - ); - taskFile.metadata = { - contentPostIntent: "preview", - contentPostLabel: "渠道预览稿", - contentPostPlatformLabel: "小红书", - }; + it("sessionView 存在但没有默认主稿时,Session · Main 应回退渲染会话过程面板", async () => { + const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => ( +
+ {target.kind}:{target.title} +
+ )); + const renderSessionPanel = vi.fn(() => ( +
session-runtime-panel
+ )); const container = mount({ artifacts: [], canvasState: null, - taskFiles: [taskFile], + taskFiles: [], workspaceRoot: "/workspace", workspaceUnavailable: false, defaultPreview: null, loadFilePreview: vi.fn(async (path: string) => ({ path, - content: "# 春日咖啡活动\n\n首屏预览", + content: "", isBinary: false, size: 0, error: null, })), onOpenPath: vi.fn(async () => undefined), onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => ( -
- {options?.stackedWorkbenchTrigger} - {target.kind}:{target.title} -
- ), + renderPreview, + sessionView: { + eyebrow: "Session Runtime", + title: "执行过程", + subtitle: "展示技能、工具和待处理交互。", + badges: [ + { + key: "session-status", + label: "执行中", + tone: "accent", + }, + ], + renderPanel: renderSessionPanel, + }, }); await flushEffects(); - clickButtonByLabel(container, "切换画布标签-全部文件"); - await flushEffects(); - - expect(container.textContent).toContain("渠道预览稿"); - expect(container.textContent).toContain("content-posts/restored-preview.md"); + expect( + container.querySelector('[data-testid="canvas-workbench-panel-session"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="session-view-panel"]') + ?.textContent, + ).toContain("session-runtime-panel"); + expect(renderSessionPanel).toHaveBeenCalled(); + expect(renderPreview).not.toHaveBeenCalled(); }); - it("工作区文件为二进制时应展示不支持预览提示", async () => { - const previewTargets: CanvasWorkbenchPreviewTarget[] = []; + it("workspaceView 存在时,应优先使用运行时注入的头部语义", async () => { + const container = mount({ + artifacts: [createArtifact("artifact-1", "draft.md", "标题\n内容", 20)], + canvasState: null, + taskFiles: [createTaskFile("task-1", "notes.md", "# notes", 30)], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: null, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "README 内容", + isBinary: false, + size: 12, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target) => ( +
+ {target.kind}:{target.title} +
+ ), + workspaceView: { + eyebrow: "Runtime Workspace", + tabLabel: "项目文件", + tabBadge: "已连接", + tabBadgeTone: "sky", + title: "工作区文件", + subtitle: "运行时已经为 workspace 汇总了目录语义。", + panelCopy: { + emptyText: "工作区空态来自运行时。", + unavailableText: "工作区不可用提示来自运行时。", + sectionEyebrow: "运行时目录", + loadingText: "目录加载文案来自运行时。", + emptyDirectoryText: "目录空态来自运行时。", + }, + badges: [ + { + key: "workspace-runtime", + label: "已连接", + tone: "accent", + }, + ], + summaryStats: [ + { + key: "workspace-runtime-stat", + label: "目录状态", + value: "运行时注入", + detail: "workspace 头部信息不再由布局壳推断。", + tone: "success", + }, + ], + }, + }); + await flushEffects(); + + expect( + container.querySelector('button[aria-label="切换画布标签-项目文件"]'), + ).not.toBeNull(); + + clickByAriaLabel(container, "切换画布标签-项目文件"); + await flushEffects(); + + expect(container.textContent).toContain("运行时目录"); + }); + + it("workspaceView 的 panelCopy 应覆盖空态与不可用提示", async () => { + const unavailableContainer = mount({ + artifacts: [], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: true, + defaultPreview: null, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: null, + isBinary: true, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target) =>
{target.title}
, + workspaceView: { + panelCopy: { + unavailableText: "工作区不可用提示来自运行时。", + }, + }, + }); + + await flushEffects(); + + clickByAriaLabel(unavailableContainer, "切换画布标签-文件"); + await flushEffects(); + expect(unavailableContainer.textContent).toContain( + "工作区不可用提示来自运行时。", + ); + + const emptyWorkspaceContainer = mount({ + artifacts: [], + canvasState: null, + taskFiles: [], + workspaceRoot: null, + workspaceUnavailable: false, + defaultPreview: null, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: null, + isBinary: true, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target) =>
{target.title}
, + workspaceView: { + panelCopy: { + emptyText: "工作区空态来自运行时。", + }, + }, + }); + + await flushEffects(); + + clickByAriaLabel(emptyWorkspaceContainer, "切换画布标签-文件"); + await flushEffects(); + expect(emptyWorkspaceContainer.textContent).toContain( + "工作区空态来自运行时。", + ); + }); + + it("sessionView 存在且有默认主稿时,应优先展示主稿预览", async () => { + const renderSessionPanel = vi.fn(() => ( +
session-runtime-panel
+ )); + const container = mount({ + artifacts: [], + canvasState: null, + taskFiles: [ + createTaskFile("task-current", "draft.md", "# 标题\n\n当前主稿", 30), + ], + selectedFileId: "task-current", + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: { + selectionKey: "task:task-current", + title: "draft.md", + content: "# 标题\n\n当前主稿", + filePath: "draft.md", + absolutePath: "/workspace/draft.md", + previousContent: null, + } satisfies CanvasWorkbenchDefaultPreview, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "", + isBinary: false, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target) => ( +
+ {target.kind}:{target.title} +
+ ), + sessionView: { + eyebrow: "Session Runtime", + title: "Session · Main", + subtitle: "统一展示过程与主稿焦点。", + badges: [ + { + key: "session-status", + label: "执行中", + tone: "accent", + }, + { + key: "session-runtime-items", + label: "轨迹 3", + tone: "default", + }, + ], + renderPanel: renderSessionPanel, + }, + }); + + await flushEffects(); + + expect( + container.querySelector('[data-testid="canvas-workbench-panel-document"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="canvas-workbench-panel-session"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="preview-panel"]')?.textContent, + ).toContain("default-canvas:draft.md"); + expect( + container.querySelector('[data-testid="session-view-panel"]'), + ).toBeNull(); + expect(renderSessionPanel).not.toHaveBeenCalled(); + }); + + it("sessionView 首次落在过程页时,后续出现真实主稿应自动切到文件标签", async () => { + const renderSessionPanel = vi.fn(() => ( +
session-runtime-panel
+ )); + const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => ( +
+ {target.kind}:{target.title} +
+ )); + + const baseProps: React.ComponentProps = { + artifacts: [], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: null, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "", + isBinary: false, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview, + sessionView: { + title: "Session · Main", + renderPanel: renderSessionPanel, + }, + }; + + const harness = mountHarness(baseProps); + await flushEffects(); + + expect( + harness.container.querySelector('[data-testid="canvas-workbench-panel-session"]'), + ).not.toBeNull(); + expect( + harness.container.querySelector('[data-testid="session-view-panel"]'), + ).not.toBeNull(); + + harness.rerender({ + ...baseProps, + taskFiles: [ + createTaskFile("task-current", "index.md", "# 标题\n\n当前主稿", 30), + ], + selectedFileId: "task-current", + defaultPreview: { + selectionKey: "task:task-current", + title: "index.md", + content: "# 标题\n\n当前主稿", + filePath: "index.md", + absolutePath: "/workspace/index.md", + previousContent: null, + } satisfies CanvasWorkbenchDefaultPreview, + }); + await flushEffects(); + + expect( + harness.container.querySelector( + '[data-testid="canvas-workbench-panel-document"]', + ), + ).not.toBeNull(); + expect( + harness.container.querySelector('[data-testid="session-view-panel"]'), + ).toBeNull(); + expect( + harness.container.querySelector('[data-testid="preview-panel"]') + ?.textContent, + ).toContain("default-canvas:index.md"); + }); + + it("工作区文件为二进制时应在文件标签内显示 unsupported 目标", async () => { const container = mount({ artifacts: [], canvasState: null, @@ -781,41 +1195,145 @@ describe("CanvasWorkbenchLayout", () => { })), onOpenPath: vi.fn(async () => undefined), onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => { - previewTargets.push(target); - return ( -
- {options?.stackedWorkbenchTrigger} - {target.kind}:{target.title} -
- ); - }, + renderPreview: (target) => ( +
+ {target.kind}:{target.title} +
+ ), }); await flushEffects(); - clickButtonByLabel(container, "切换画布标签-全部文件"); + clickByAriaLabel(container, "切换画布标签-文件"); await flushEffects(); - clickButtonByLabel(container, "展开目录-src"); + clickByAriaLabel(container, "展开目录-src"); await flushEffects(); - clickButtonByLabel(container, "选择工作区文件-binary.dat"); + clickByAriaLabel(container, "选择工作区文件-binary.dat"); await flushEffects(); - expect(previewTargets.at(-1)?.kind).toBe("unsupported"); - - clickButtonByLabel(container, "切换画布标签-预览"); - expect(container.textContent).toContain("该文件为二进制内容"); + expect( + container.querySelector('[data-testid="preview-panel"]')?.textContent, + ).toContain("unsupported:binary.dat"); }); - it("启用 teamView 且没有默认预览时应优先展示团队预览,并默认收起右侧工作台", async () => { - const renderPreview = vi.fn((_target: CanvasWorkbenchPreviewTarget) => ( -
fallback
- )); - const renderTeamPreview = vi.fn( - (_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => ( -
team-preview
+ it("重新展开结果目录下的 images 时应刷新目录子项,避免沿用空缓存", async () => { + let imageListingRequestCount = 0; + mockListDirectory.mockImplementation(async (path: string) => { + if (path === "/workspace/exports/x-article-export/latest") { + return { + path, + parentPath: "/workspace/exports/x-article-export", + error: null, + entries: [ + { + name: "images", + path: "/workspace/exports/x-article-export/latest/images", + isDir: true, + size: 0, + modifiedAt: 100, + }, + { + name: "index.md", + path: "/workspace/exports/x-article-export/latest/index.md", + isDir: false, + size: 2048, + modifiedAt: 100, + }, + ], + }; + } + + if (path === "/workspace/exports/x-article-export/latest/images") { + imageListingRequestCount += 1; + return { + path, + parentPath: "/workspace/exports/x-article-export/latest", + error: null, + entries: + imageListingRequestCount === 1 + ? [] + : [ + { + name: "image-1.jpg", + path: `${path}/image-1.jpg`, + isDir: false, + size: 1024, + modifiedAt: 100, + }, + { + name: "image-2.jpg", + path: `${path}/image-2.jpg`, + isDir: false, + size: 2048, + modifiedAt: 100, + }, + ], + }; + } + + return { + path, + parentPath: "/workspace", + error: null, + entries: [], + }; + }); + + const container = mount({ + artifacts: [], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: { + selectionKey: "default-preview:exports/x-article-export/latest/index.md", + title: "index.md", + content: "# 导出结果\n\n这是正文。", + filePath: "exports/x-article-export/latest/index.md", + absolutePath: "/workspace/exports/x-article-export/latest/index.md", + previousContent: null, + }, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "# 文件内容", + isBinary: false, + size: 128, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target) => ( +
+ {target.kind}:{target.title} +
), + }); + + await flushEffects(); + clickByAriaLabel(container, "切换画布标签-文件"); + await flushEffects(); + + clickByAriaLabel(container, "展开目录-images"); + await flushEffects(); + expect(container.textContent).not.toContain("image-1.jpg"); + + clickByAriaLabel(container, "折叠目录-images"); + await flushEffects(); + clickByAriaLabel(container, "展开目录-images"); + await flushEffects(); + + expect(mockListDirectory).toHaveBeenCalledWith( + "/workspace/exports/x-article-export/latest/images", ); + expect(imageListingRequestCount).toBe(2); + expect(container.textContent).toContain("image-1.jpg"); + expect(container.textContent).toContain("image-2.jpg"); + }); + + it("启用 teamView 且没有默认预览时应默认落在 team 标签", async () => { + const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => ( +
preview:{target.kind}
+ )); const renderTeamPanel = vi.fn(() => (
team-panel
)); @@ -840,8 +1358,29 @@ describe("CanvasWorkbenchLayout", () => { teamView: { enabled: true, title: "Team Workbench", - subtitle: "多 agent 实时协作", - renderPreview: renderTeamPreview, + subtitle: "多成员实时协作", + badges: [ + { + key: "team-runtime", + label: "Team Workbench", + tone: "accent", + }, + { + key: "team-trigger-state", + label: "处理中", + tone: "accent", + }, + ], + summaryStats: [ + { + key: "team-status", + label: "协作状态", + value: "处理中", + detail: "2 位处理中,1 位排队中。", + tone: "accent", + }, + ], + renderPreview: () =>
unused-team-preview
, renderPanel: renderTeamPanel, }, }); @@ -849,29 +1388,26 @@ describe("CanvasWorkbenchLayout", () => { await flushEffects(); expect( - container.querySelector('[data-testid="team-preview"]'), + container.querySelector('[data-testid="canvas-workbench-panel-team"]'), ).not.toBeNull(); - expect(container.querySelector('[data-testid="team-panel"]')).toBeNull(); expect( - container.querySelector('button[aria-label="展开画布工作台"]'), + container.querySelector('[data-testid="preview-panel"]')?.textContent, + ).toContain("preview:team-workbench"); + expect( + container.querySelector('[data-testid="team-panel"]'), ).not.toBeNull(); - expect(container.textContent).toContain("团队"); - expect(renderTeamPreview).toHaveBeenCalled(); - expect(renderTeamPanel).not.toHaveBeenCalled(); - expect(renderPreview).not.toHaveBeenCalled(); + expect( + container.querySelector( + 'button[aria-label="切换画布标签-Team Workbench"]', + ), + ).not.toBeNull(); + expect(renderPreview).toHaveBeenCalled(); + expect(renderTeamPanel).toHaveBeenCalled(); }); - it("teamView 的 autoFocusToken 变化时应切到 Team Workbench", async () => { + it("teamView 的 autoFocusToken 变化时应切到 team 标签", async () => { const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => ( -
fallback:{target.kind}
- )); - const renderTeamPreview = vi.fn( - (_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => ( -
team-preview
- ), - ); - const renderTeamPanel = vi.fn(() => ( -
team-panel
+
preview:{target.kind}
)); const baseProps: React.ComponentProps = { @@ -905,8 +1441,8 @@ describe("CanvasWorkbenchLayout", () => { title: "Team Workbench", subtitle: "多成员实时协作", autoFocusToken: 1, - renderPreview: renderTeamPreview, - renderPanel: renderTeamPanel, + renderPreview: () =>
unused-team-preview
, + renderPanel: () =>
team-panel
, }, }; @@ -914,13 +1450,9 @@ describe("CanvasWorkbenchLayout", () => { await flushEffects(); expect( - harness.container.querySelector('[data-testid="team-preview"]'), - ).toBeNull(); - expect( - harness.container.querySelector('[data-testid="team-panel"]'), - ).toBeNull(); - expect( - harness.container.querySelector('[data-testid="fallback-preview"]'), + harness.container.querySelector( + '[data-testid="canvas-workbench-panel-document"]', + ), ).not.toBeNull(); harness.rerender({ @@ -933,74 +1465,20 @@ describe("CanvasWorkbenchLayout", () => { await flushEffects(); expect( - harness.container.querySelector('[data-testid="team-preview"]'), + harness.container.querySelector( + '[data-testid="canvas-workbench-panel-team"]', + ), ).not.toBeNull(); expect( harness.container.querySelector('[data-testid="team-panel"]'), ).not.toBeNull(); - expect(harness.container.textContent).toContain("多成员实时协作"); + expect( + harness.container.querySelector('[data-testid="preview-panel"]') + ?.textContent, + ).toContain("preview:team-workbench"); }); - it("teamView 存在活动态提示时,应在窄屏悬浮入口显示状态标签", async () => { - const container = mount({ - artifacts: [ - createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20), - ], - canvasState: null, - taskFiles: [], - workspaceRoot: "/workspace", - workspaceUnavailable: false, - defaultPreview: { - selectionKey: "artifact:artifact-1", - title: "draft.md", - content: "标题\n当前内容", - filePath: "draft.md", - absolutePath: "/workspace/draft.md", - previousContent: null, - }, - loadFilePreview: vi.fn(async (path: string) => ({ - path, - content: "", - isBinary: false, - size: 0, - error: null, - })), - onOpenPath: vi.fn(async () => undefined), - onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => ( -
- {options?.stackedWorkbenchTrigger} - {target.kind}:{target.title} -
- ), - teamView: { - enabled: true, - title: "Team Workbench", - subtitle: "多成员实时协作", - triggerState: { - tone: "active", - label: "组建中", - }, - renderPreview: () =>
team-preview
, - renderPanel: () =>
team-panel
, - }, - }); - - await flushEffects(); - await resizeWorkbench(820); - await flushEffects(); - - const trigger = container.querySelector( - '[data-testid="canvas-workbench-trigger"]', - ); - - expect(trigger).toBeTruthy(); - expect(trigger?.textContent).toContain("工作台"); - expect(trigger?.textContent).toContain("组建中"); - expect(trigger?.className).toContain("bg-sky-50"); - }); - - it("容器变窄时应切换为右侧抽屉工作台布局并保持工作台可展开收起", async () => { + it("容器变窄时应继续保持顶部标签壳,但 data-layout-mode 切到 stacked", async () => { const container = mount({ artifacts: [ createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20), @@ -1029,9 +1507,8 @@ describe("CanvasWorkbenchLayout", () => { })), onOpenPath: vi.fn(async () => undefined), onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => ( + renderPreview: (target) => (
- {options?.stackedWorkbenchTrigger} {target.kind}:{target.title}
), @@ -1044,11 +1521,6 @@ describe("CanvasWorkbenchLayout", () => { .querySelector('[data-testid="canvas-workbench-shell"]') ?.getAttribute("data-layout-mode"), ).toBe("split"); - expect( - container - .querySelector('[data-testid="canvas-workbench-layout"]') - ?.getAttribute("data-panel-placement"), - ).toBe("side"); await resizeWorkbench(820); await flushEffects(); @@ -1060,288 +1532,19 @@ describe("CanvasWorkbenchLayout", () => { ).toBe("stacked"); expect( container.querySelector('button[aria-label="展开画布工作台"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="canvas-workbench-trigger"]') - ?.textContent, - ).toContain("工作台"); - - clickButtonByLabel(container, "展开画布工作台"); - expect( - container - .querySelector('[data-testid="canvas-workbench-layout"]') - ?.getAttribute("data-panel-placement"), - ).toBe("overlay-right"); - expect( - container.querySelector('button[aria-label="折叠画布工作台"]'), - ).not.toBeNull(); - expect(container.textContent).toContain("右侧工作台"); - expect(container.textContent).toContain("产物、文件、变更与预览"); - - clickButtonByLabel(container, "折叠画布工作台"); - expect( - container.querySelector('button[aria-label="展开画布工作台"]'), - ).not.toBeNull(); - - clickButtonByLabel(container, "展开画布工作台"); - clickButtonByLabel(container, "切换画布标签-预览"); - expect(container.textContent).toContain("当前画布正文"); - }); - - it("Team 全屏预览时主预览区应占满剩余宽度,而不是收缩成窄列", async () => { - const container = mount({ - artifacts: [], - canvasState: null, - taskFiles: [], - workspaceRoot: "/workspace", - workspaceUnavailable: false, - defaultPreview: null, - loadFilePreview: vi.fn(async (path: string) => ({ - path, - content: "README 内容", - isBinary: false, - size: 12, - error: null, - })), - onOpenPath: vi.fn(async () => undefined), - onRevealPath: vi.fn(async () => undefined), - renderPreview: () =>
fallback
, - teamView: { - enabled: true, - title: "Team Workbench", - subtitle: "多成员实时协作", - preferFullscreenPreview: true, - renderPreview: () =>
team-preview
, - renderPanel: () =>
team-panel
, - }, - }); - - await flushEffects(); - - expect( - container.querySelector('[data-testid="team-preview"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="canvas-workbench-layout"]'), ).toBeNull(); - - const previewRegion = container.querySelector( - '[data-testid="canvas-workbench-preview-region"]', - ); - expect(previewRegion).not.toBeNull(); - expect(previewRegion?.className).toContain("flex-1"); - expect(previewRegion?.className).toContain("h-full"); - }); - - it("Team 固定工作台在桌面宽度下应保持右侧侧栏,并默认以闭合态展示", async () => { - const container = mount({ - artifacts: [], - canvasState: null, - taskFiles: [], - workspaceRoot: "/workspace", - workspaceUnavailable: false, - defaultPreview: null, - loadFilePreview: vi.fn(async (path: string) => ({ - path, - content: "README 内容", - isBinary: false, - size: 12, - error: null, - })), - onOpenPath: vi.fn(async () => undefined), - onRevealPath: vi.fn(async () => undefined), - renderPreview: () =>
fallback
, - teamView: { - enabled: true, - title: "Team Workbench", - subtitle: "多成员实时协作", - preferFixedPanel: true, - renderPreview: () =>
team-preview
, - renderPanel: () =>
team-panel
, - }, - }); - - await flushEffects(); - await resizeWorkbench(900); - expect( - container - .querySelector('[data-testid="canvas-workbench-layout"]') - ?.getAttribute("data-panel-placement"), - ).toBe("side"); - expect( - container.querySelector('[data-testid="canvas-workbench-trigger"]'), - ).toBeNull(); - expect(container.querySelector('[data-testid="team-panel"]')).toBeNull(); - expect( - container.querySelector('button[aria-label="展开画布工作台"]'), - ).not.toBeNull(); - }); - - it("窄屏右侧抽屉工作台应支持拖拽调整宽度", async () => { - const container = mount({ - artifacts: [ - createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20), - ], - canvasState: null, - taskFiles: [], - workspaceRoot: "/workspace", - workspaceUnavailable: false, - defaultPreview: { - selectionKey: "artifact:artifact-new", - title: "draft.md", - content: "标题\n产物版本", - filePath: "draft.md", - absolutePath: "/workspace/draft.md", - previousContent: "标题\n上一版本", - } satisfies CanvasWorkbenchDefaultPreview, - loadFilePreview: vi.fn(async (path: string) => ({ - path, - content: "README 内容", - isBinary: false, - size: 12, - error: null, - })), - onOpenPath: vi.fn(async () => undefined), - onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => ( -
- {options?.stackedWorkbenchTrigger} - {target.kind}:{target.title} -
+ container.querySelector( + 'button[aria-label="切换画布标签-Session · Main"]', ), - }); + ).not.toBeNull(); + clickByAriaLabel(container, "切换画布标签-文件"); await flushEffects(); - await resizeWorkbench(820, 640); - await flushEffects(); - - clickButtonByLabel(container, "展开画布工作台"); - await flushEffects(); - - const layout = container.querySelector( - '[data-testid="canvas-workbench-layout"]', - ); - const resizeHandle = container.querySelector( - '[data-testid="canvas-workbench-resize-handle"]', - ); - - expect(layout).toBeTruthy(); - expect(resizeHandle).toBeTruthy(); - - const initialWidth = Number.parseFloat(layout?.style.width || "0"); - expect(initialWidth).toBeGreaterThan(0); - - await act(async () => { - resizeHandle?.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - clientX: 620, - }), - ); - window.dispatchEvent( - new MouseEvent("mousemove", { - bubbles: true, - clientX: 520, - }), - ); - window.dispatchEvent( - new MouseEvent("mouseup", { - bubbles: true, - clientX: 520, - }), - ); - await Promise.resolve(); - }); - - const expandedWidth = Number.parseFloat(layout?.style.width || "0"); - expect(expandedWidth).toBeGreaterThan(initialWidth); - - await act(async () => { - resizeHandle?.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - clientX: 520, - }), - ); - window.dispatchEvent( - new MouseEvent("mousemove", { - bubbles: true, - clientX: 660, - }), - ); - window.dispatchEvent( - new MouseEvent("mouseup", { - bubbles: true, - clientX: 660, - }), - ); - await Promise.resolve(); - }); - - const reducedWidth = Number.parseFloat(layout?.style.width || "0"); - expect(reducedWidth).toBeLessThan(expandedWidth); - expect(reducedWidth).toBeGreaterThanOrEqual(280); - }); - - it("窄屏工作台抽屉打开后,收到聊天抽屉打开事件应自动收起", async () => { - const container = mount({ - artifacts: [ - createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20), - ], - canvasState: null, - taskFiles: [], - workspaceRoot: "/workspace", - workspaceUnavailable: false, - defaultPreview: { - selectionKey: "artifact:artifact-new", - title: "draft.md", - content: "标题\n产物版本", - filePath: "draft.md", - absolutePath: "/workspace/draft.md", - previousContent: "标题\n上一版本", - } satisfies CanvasWorkbenchDefaultPreview, - loadFilePreview: vi.fn(async (path: string) => ({ - path, - content: "README 内容", - isBinary: false, - size: 12, - error: null, - })), - onOpenPath: vi.fn(async () => undefined), - onRevealPath: vi.fn(async () => undefined), - renderPreview: (target, options) => ( -
- {options?.stackedWorkbenchTrigger} - {target.kind}:{target.title} -
+ expect( + container.querySelector( + '[data-testid="canvas-workbench-panel-workspace"]', ), - }); - - await flushEffects(); - await resizeWorkbench(820, 640); - await flushEffects(); - - clickButtonByLabel(container, "展开画布工作台"); - await flushEffects(); - - expect( - container - .querySelector('[data-testid="canvas-workbench-layout"]') - ?.getAttribute("data-panel-placement"), - ).toBe("overlay-right"); - - await act(async () => { - emitCompactRightPanelOpen({ source: "chat" }); - await Promise.resolve(); - }); - await flushEffects(); - - expect( - container.querySelector('[data-testid="canvas-workbench-layout"]'), - ).toBeNull(); - expect( - container.querySelector('[data-testid="canvas-workbench-trigger"]'), ).not.toBeNull(); }); }); diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index 621af6bd3..e6d51c223 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -5,7 +5,6 @@ import { useMemo, useRef, useState, - type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { @@ -20,23 +19,13 @@ import { FolderOpen, GitCompare, Loader2, - PanelRightClose, - PanelRightOpen, RefreshCw, + X, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { Badge } from "@/components/ui/badge"; -import { CompactRightDockButton } from "@/components/ui/compact-right-dock-button"; -import { - CompactRightDrawerHeader, - CompactRightDrawerIconButton, -} from "@/components/ui/compact-right-drawer-header"; -import { - emitCompactRightPanelOpen, - onCompactRightPanelOpen, -} from "@/lib/compactRightPanelEvents"; import { listDirectory, type DirectoryListing } from "@/lib/api/fileBrowser"; import type { Artifact } from "@/lib/artifact/types"; import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; @@ -56,19 +45,21 @@ import { } from "../utils/canvasWorkbenchDiff"; import { extractFileNameFromPath, + normalizeManagedWorkspacePathForDisplay, resolveAbsoluteWorkspacePath, } from "../workspace/workspacePath"; +import { filterWorkspaceDirectoryListing } from "../workspace/workspaceTreeVisibility"; import { ArtifactWorkbenchDocumentInspector, type ArtifactWorkbenchDocumentController, } from "../workspace/artifactWorkbenchDocument"; type CanvasWorkbenchTab = - | "artifacts" - | "files" - | "changes" - | "preview" - | "team"; + | "session" + | "workspace" + | "team" + | `document:${string}`; +type CanvasWorkbenchDocumentViewMode = "preview" | "changes"; export type CanvasWorkbenchLayoutMode = "split" | "stacked"; interface CanvasWorkbenchEntryBase { @@ -113,6 +104,43 @@ export interface CanvasWorkbenchDefaultPreview { previousContent?: string | null; } +export type CanvasWorkbenchHeaderBadgeTone = "default" | "accent" | "success"; + +export interface CanvasWorkbenchHeaderBadge { + key: string; + label: string; + tone?: CanvasWorkbenchHeaderBadgeTone; +} + +export interface CanvasWorkbenchSummaryStat { + key: string; + label: string; + value: string; + detail: string; + tone?: CanvasWorkbenchHeaderBadgeTone; +} + +export interface CanvasWorkbenchPanelCopy { + introText?: string; + emptyText?: string; + unavailableText?: string; + sectionEyebrow?: string; + loadingText?: string; + emptyDirectoryText?: string; +} + +export interface CanvasWorkbenchHeaderView { + eyebrow?: string; + title?: string; + subtitle?: string; + badges?: CanvasWorkbenchHeaderBadge[]; + summaryStats?: CanvasWorkbenchSummaryStat[]; + tabLabel?: string; + tabBadge?: string; + tabBadgeTone?: "slate" | "sky" | "rose"; + panelCopy?: CanvasWorkbenchPanelCopy; +} + export type CanvasWorkbenchPreviewTarget = | { kind: "default-canvas"; @@ -157,10 +185,8 @@ export type CanvasWorkbenchPreviewTarget = title: string; }; -export interface CanvasWorkbenchTeamView { +export interface CanvasWorkbenchTeamView extends CanvasWorkbenchHeaderView { enabled: boolean; - title?: string; - subtitle?: string; autoFocusToken?: string | number | null; preferFullscreenPreview?: boolean; preferFixedPanel?: boolean; @@ -175,6 +201,10 @@ export interface CanvasWorkbenchTeamView { renderFooter?: () => ReactNode; } +export interface CanvasWorkbenchSessionView extends CanvasWorkbenchHeaderView { + renderPanel: () => ReactNode; +} + interface WorkspaceFileSelection { path: string; title: string; @@ -184,6 +214,23 @@ interface WorkspaceFileSelection { size?: number; } +interface CanvasWorkbenchResolvedSelection { + selectionKey: string | null; + entrySource: + | CanvasWorkbenchEntry["source"] + | "workspace-file" + | "default-preview"; + title: string; + tabLabel: string; + subtitle?: string; + kindLabel: string; + badgeLabel?: string; + target: CanvasWorkbenchPreviewTarget; + content: string; + previousContent: string | null; + selectionPath?: string; +} + export interface CanvasWorkbenchLayoutProps { artifacts: Artifact[]; canvasState: CanvasStateUnion | null; @@ -205,9 +252,14 @@ export interface CanvasWorkbenchLayoutProps { }, ) => ReactNode; onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void; + workspaceView?: CanvasWorkbenchHeaderView | null; teamView?: CanvasWorkbenchTeamView | null; + sessionView?: CanvasWorkbenchSessionView | null; } +const WORKBENCH_SHELL_CLASSNAME = + "rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5"; + const WORKBENCH_PANEL_CLASSNAME = "rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5"; @@ -215,48 +267,190 @@ const WORKBENCH_MUTED_PANEL_CLASSNAME = "rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500"; const WORKBENCH_BUTTON_CLASSNAME = - "border-slate-200/80 bg-white/90 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"; + "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"; const WORKBENCH_ACTIVE_BUTTON_CLASSNAME = - "border-slate-300 bg-slate-100 text-slate-900"; + "border-slate-300 bg-white text-slate-950 shadow-sm shadow-slate-950/5"; const WORKBENCH_GHOST_BUTTON_CLASSNAME = "border-slate-200/80 text-slate-500 hover:bg-slate-50 hover:text-slate-900"; const STACKED_LAYOUT_BREAKPOINT = 1040; -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} - -function resolveStackedWorkbenchMetrics(shellWidth: number): { - minWidth: number; - maxWidth: number; - defaultWidth: number; -} { - const safeShellWidth = - shellWidth > 0 ? shellWidth : STACKED_LAYOUT_BREAKPOINT; - const minWidth = Math.max( - 280, - Math.min(340, Math.round(safeShellWidth * 0.36)), - ); - const maxWidth = Math.max(minWidth + 40, Math.min(480, safeShellWidth - 28)); - const defaultWidth = clamp( - Math.round(safeShellWidth * 0.42), - minWidth, - maxWidth, - ); - return { - minWidth, - maxWidth, - defaultWidth, - }; -} - function normalizePath(value: string): string { return value.replace(/\\/g, "/"); } +function resolveWorkspaceRelativeDisplayPath( + workspaceRoot: string | null | undefined, + path: string | null | undefined, +): string | undefined { + const normalizedPath = normalizePath(path?.trim() || ""); + if (!normalizedPath) { + return undefined; + } + + const normalizedRoot = normalizePath(workspaceRoot?.trim() || ""); + if (!normalizedRoot) { + return normalizedPath; + } + + if (normalizedPath === normalizedRoot) { + return extractFileNameFromPath(normalizedPath); + } + + const prefix = `${normalizedRoot}/`; + if (normalizedPath.startsWith(prefix)) { + return normalizedPath.slice(prefix.length); + } + + return normalizedPath; +} + +function resolveWorkspaceRelativePath( + workspaceRoot: string | null | undefined, + path: string | null | undefined, +): string | null { + const normalizedPath = normalizePath(path?.trim() || ""); + if (!normalizedPath) { + return null; + } + + const normalizedRoot = normalizePath(workspaceRoot?.trim() || ""); + if (!normalizedRoot) { + return normalizedPath; + } + + if (normalizedPath === normalizedRoot) { + return ""; + } + + const prefix = `${normalizedRoot}/`; + if (normalizedPath.startsWith(prefix)) { + return normalizedPath.slice(prefix.length); + } + + if (!/^(\/|[A-Za-z]:\/|\\\\)/.test(normalizedPath)) { + return normalizedPath; + } + + return null; +} + +function resolveSavedContentBundleRoot( + workspaceRoot: string | null | undefined, + selectionPath: string | null | undefined, +): string | null { + const relativePath = resolveWorkspaceRelativePath(workspaceRoot, selectionPath); + if (!relativePath) { + return null; + } + + const match = relativePath.match(/^(exports\/[^/]+\/[^/]+)/); + if (!match?.[1]) { + return null; + } + + return resolveAbsoluteWorkspacePath(workspaceRoot, match[1]) || null; +} + +function resolveWorkspacePanelDisplayPath( + workspaceRoot: string | null | undefined, + panelRootPath: string | null | undefined, +): string | undefined { + const normalizedPanelRoot = normalizePath(panelRootPath?.trim() || ""); + if (!normalizedPanelRoot) { + return undefined; + } + + const normalizedWorkspaceRoot = normalizePath(workspaceRoot?.trim() || ""); + if ( + normalizedWorkspaceRoot && + normalizedPanelRoot !== normalizedWorkspaceRoot + ) { + return ( + resolveWorkspaceRelativeDisplayPath(workspaceRoot, panelRootPath) || + normalizeManagedWorkspacePathForDisplay(normalizedPanelRoot) + ); + } + + return normalizeManagedWorkspacePathForDisplay(normalizedPanelRoot); +} + +function isSavedContentBundleDirectory( + workspaceRoot: string | null | undefined, + listingPath: string, +): boolean { + const relativePath = resolveWorkspaceRelativePath(workspaceRoot, listingPath); + return Boolean(relativePath?.match(/^exports\/[^/]+\/[^/]+(?:\/.*)?$/)); +} + +function compareWorkspaceTreeEntryName(left: string, right: string): number { + return left.localeCompare(right, "zh-CN", { + numeric: true, + sensitivity: "base", + }); +} + +function sortWorkspaceListingEntries( + entries: DirectoryListing["entries"], + listingPath: string, + workspaceRoot: string | null | undefined, +): DirectoryListing["entries"] { + const isBundleDirectory = isSavedContentBundleDirectory( + workspaceRoot, + listingPath, + ); + + const resolveRank = (entry: DirectoryListing["entries"][number]) => { + const normalizedName = (entry.name || "").trim().toLowerCase(); + + if (isBundleDirectory) { + if (normalizedName === "index.md") { + return 0; + } + if (normalizedName === "agents.md") { + return 1; + } + if (entry.isDir && normalizedName === "skills") { + return 2; + } + if (entry.isDir && (normalizedName === "images" || normalizedName === "assets")) { + return 3; + } + if (entry.isDir) { + return 4; + } + if (/\.(md|markdown|mdx)$/i.test(normalizedName)) { + return 5; + } + if (/\.(png|jpe?g|webp|gif|svg)$/i.test(normalizedName)) { + return 6; + } + if (/\.json$/i.test(normalizedName)) { + return 8; + } + return 7; + } + + if (entry.isDir) { + return 0; + } + if (/\.(md|markdown|mdx)$/i.test(normalizedName)) { + return 1; + } + return 2; + }; + + return [...entries].sort((left, right) => { + const rankDiff = resolveRank(left) - resolveRank(right); + if (rankDiff !== 0) { + return rankDiff; + } + return compareWorkspaceTreeEntryName(left.name, right.name); + }); +} + function buildSyntheticArtifact( id: string, filePath: string, @@ -499,6 +693,252 @@ function renderDiffState(diffLines: CanvasWorkbenchDiffLine[]): ReactNode { ); } +function buildDocumentTabKey(selectionKey: string): `document:${string}` { + return `document:${selectionKey}`; +} + +function isDocumentTabKey( + value: CanvasWorkbenchTab | string, +): value is `document:${string}` { + return value.startsWith("document:"); +} + +function parseDocumentTabKey(tabKey: `document:${string}` | string): string { + return tabKey.replace(/^document:/, ""); +} + +function resolvePreviewContent(target: CanvasWorkbenchPreviewTarget): string { + if (target.kind === "default-canvas") { + return target.content; + } + + if (target.kind === "artifact" || target.kind === "synthetic-artifact") { + return target.artifact.content; + } + + return ""; +} + +function resolvePreviewPath( + target: CanvasWorkbenchPreviewTarget, +): string | undefined { + if ( + target.kind === "default-canvas" || + target.kind === "artifact" || + target.kind === "synthetic-artifact" || + target.kind === "loading" || + target.kind === "unsupported" + ) { + return target.absolutePath || target.filePath; + } + + return undefined; +} + +function buildDefaultPreviewSelection( + defaultPreview: CanvasWorkbenchDefaultPreview, +): CanvasWorkbenchResolvedSelection { + const target: CanvasWorkbenchPreviewTarget = { + kind: "default-canvas", + title: defaultPreview.title, + content: defaultPreview.content, + filePath: defaultPreview.filePath, + absolutePath: defaultPreview.absolutePath, + }; + const fileLabel = extractFileNameFromPath( + defaultPreview.filePath || defaultPreview.title, + ); + + return { + selectionKey: defaultPreview.selectionKey || null, + entrySource: "default-preview", + title: defaultPreview.title, + tabLabel: fileLabel || defaultPreview.title, + subtitle: defaultPreview.filePath, + kindLabel: "主稿", + target, + content: defaultPreview.content, + previousContent: defaultPreview.previousContent || null, + selectionPath: resolvePreviewPath(target), + }; +} + +function resolveSelectionContext({ + selectionKey, + defaultPreview, + entryMap, + workspaceFileSelections, + canvasState, + artifacts, + workspaceRoot, +}: { + selectionKey: string | null; + defaultPreview: CanvasWorkbenchDefaultPreview | null; + entryMap: Map; + workspaceFileSelections: Record; + canvasState: CanvasStateUnion | null; + artifacts: Artifact[]; + workspaceRoot?: string | null; +}): CanvasWorkbenchResolvedSelection | null { + if ( + selectionKey && + defaultPreview && + selectionKey === defaultPreview.selectionKey && + defaultPreview.content.trim() + ) { + return buildDefaultPreviewSelection(defaultPreview); + } + + if (!selectionKey) { + return defaultPreview ? buildDefaultPreviewSelection(defaultPreview) : null; + } + + if (selectionKey.startsWith("workspace-file:")) { + const rawPath = selectionKey.replace(/^workspace-file:/, ""); + const workspaceFile = workspaceFileSelections[selectionKey] || { + path: rawPath, + title: extractFileNameFromPath(rawPath), + status: "loading" as const, + }; + + let target: CanvasWorkbenchPreviewTarget; + if (workspaceFile.status === "loading") { + target = { + kind: "loading", + title: workspaceFile.title, + filePath: workspaceFile.path, + absolutePath: workspaceFile.path, + }; + } else if (workspaceFile.status === "binary") { + target = { + kind: "unsupported", + title: workspaceFile.title, + reason: "该文件为二进制内容,暂不支持画布文本预览。", + filePath: workspaceFile.path, + absolutePath: workspaceFile.path, + }; + } else if (workspaceFile.status === "error") { + target = { + kind: "unsupported", + title: workspaceFile.title, + reason: workspaceFile.error || "读取文件失败", + filePath: workspaceFile.path, + absolutePath: workspaceFile.path, + }; + } else { + target = { + kind: "default-canvas", + title: workspaceFile.title, + content: workspaceFile.content || "", + filePath: workspaceFile.path, + absolutePath: workspaceFile.path, + }; + } + + const displayPath = resolveWorkspaceRelativeDisplayPath( + workspaceRoot, + workspaceFile.path, + ); + + return { + selectionKey, + entrySource: "workspace-file", + title: workspaceFile.title, + tabLabel: + extractFileNameFromPath(workspaceFile.path) || workspaceFile.title, + subtitle: displayPath, + kindLabel: "文件", + target, + content: resolvePreviewContent(target), + previousContent: + workspaceFile.status === "ready" + ? resolveMappedPreviousContentForPath( + workspaceFile.path, + canvasState, + artifacts, + workspaceRoot, + ) + : null, + selectionPath: resolvePreviewPath(target), + }; + } + + const entry = entryMap.get(selectionKey) || null; + if (!entry) { + return defaultPreview ? buildDefaultPreviewSelection(defaultPreview) : null; + } + + let target: CanvasWorkbenchPreviewTarget; + if (entry.source === "artifact") { + target = { + kind: "artifact", + title: entry.title, + artifact: entry.artifact, + filePath: entry.filePath, + absolutePath: entry.absolutePath, + }; + } else if (entry.source === "document-version") { + target = { + kind: "synthetic-artifact", + title: entry.title, + artifact: buildSyntheticArtifact( + `canvas-workbench:version:${entry.version.id}`, + entry.filePath || `${entry.title}.md`, + entry.version.content, + ), + filePath: entry.filePath, + absolutePath: entry.absolutePath, + }; + } else { + target = { + kind: "synthetic-artifact", + title: entry.title, + artifact: buildSyntheticArtifact( + `canvas-workbench:task:${entry.taskFile.id}`, + entry.filePath || entry.title, + entry.taskFile.content || "", + ), + filePath: entry.filePath, + absolutePath: entry.absolutePath, + }; + } + + let previousContent: string | null = null; + if (entry.source === "artifact") { + previousContent = resolvePreviousArtifactContent(entry.artifact, artifacts); + } else if ( + entry.source === "document-version" && + isDocumentCanvasState(canvasState) + ) { + previousContent = resolvePreviousVersionContent( + entry.version, + canvasState.versions, + ); + } else if (entry.absolutePath) { + previousContent = resolveMappedPreviousContentForPath( + entry.absolutePath, + canvasState, + artifacts, + workspaceRoot, + ); + } + + return { + selectionKey, + entrySource: entry.source, + title: entry.title, + tabLabel: + extractFileNameFromPath(entry.filePath || entry.title) || entry.title, + subtitle: entry.subtitle, + kindLabel: entry.kindLabel, + badgeLabel: entry.badgeLabel, + target, + content: resolvePreviewContent(target), + previousContent, + selectionPath: resolvePreviewPath(target), + }; +} + export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ artifacts, canvasState, @@ -512,27 +952,18 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ onRevealPath, renderPreview, onLayoutModeChange, + workspaceView = null, teamView = null, + sessionView = null, }: CanvasWorkbenchLayoutProps) { const shouldPreferTeamTabByDefault = teamView?.enabled === true && !defaultPreview; const shellRef = useRef(null); - const stackedResizeCleanupRef = useRef<(() => void) | null>(null); - const [activeTab, setActiveTab] = useState(() => - shouldPreferTeamTabByDefault ? "team" : "artifacts", - ); - const [collapsed, setCollapsed] = useState( - () => shouldPreferTeamTabByDefault, - ); const [isStackedLayout, setIsStackedLayout] = useState(false); - const [stackedWorkbenchOpen, setStackedWorkbenchOpen] = useState(false); - const [shellWidth, setShellWidth] = useState(STACKED_LAYOUT_BREAKPOINT); - const [stackedWorkbenchWidth, setStackedWorkbenchWidth] = useState< - number | null - >(null); + const [documentPreviewMode, setDocumentPreviewMode] = + useState("preview"); const [documentInspectorCollapsed, setDocumentInspectorCollapsed] = useState(true); - const [selectedKey, setSelectedKey] = useState(null); const [artifactDocumentController, setArtifactDocumentController] = useState(null); const [directoryCache, setDirectoryCache] = useState< @@ -547,18 +978,6 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ const [workspaceFileSelections, setWorkspaceFileSelections] = useState< Record >({}); - const availableTabs = useMemo< - Array<{ key: CanvasWorkbenchTab; label: string }> - >( - () => [ - ...(teamView?.enabled ? [{ key: "team" as const, label: "团队" }] : []), - { key: "artifacts" as const, label: "产物" }, - { key: "files" as const, label: "全部文件" }, - { key: "changes" as const, label: "变更" }, - { key: "preview" as const, label: "预览" }, - ], - [teamView?.enabled], - ); const entries = useMemo( () => buildEntries(artifacts, canvasState, taskFiles, workspaceRoot), @@ -570,15 +989,12 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ [entries], ); - const fallbackEntryKey = useMemo(() => { - if (defaultPreview) { - if ( - defaultPreview.selectionKey && - entryMap.has(defaultPreview.selectionKey) - ) { - return defaultPreview.selectionKey; - } - return null; + const fallbackSelectionKey = useMemo(() => { + if ( + defaultPreview?.selectionKey && + entryMap.has(defaultPreview.selectionKey) + ) { + return defaultPreview.selectionKey; } if (selectedFileId) { @@ -589,47 +1005,128 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ } return entries[0]?.key || null; - }, [defaultPreview, entries, entryMap, selectedFileId]); + }, [defaultPreview?.selectionKey, entries, entryMap, selectedFileId]); - useEffect(() => { - if ( - selectedKey && - (entryMap.has(selectedKey) || selectedKey.startsWith("workspace-file:")) - ) { - return; - } - setSelectedKey(fallbackEntryKey); - }, [entryMap, fallbackEntryKey, selectedKey]); + const initialDocumentSelectionKey = + defaultPreview?.selectionKey || fallbackSelectionKey; + const shouldPreferSessionTabOnMount = Boolean( + sessionView?.renderPanel && !initialDocumentSelectionKey, + ); - const loadDirectory = useCallback(async (path: string) => { - if (!path.trim()) { - return; + const [selectedKey, setSelectedKey] = useState( + initialDocumentSelectionKey, + ); + const [openDocumentTabs, setOpenDocumentTabs] = useState< + Array<`document:${string}`> + >(() => + initialDocumentSelectionKey + ? [buildDocumentTabKey(initialDocumentSelectionKey)] + : [], + ); + const [activeTab, setActiveTab] = useState(() => { + if (shouldPreferSessionTabOnMount) { + return "session"; } - setLoadingDirectories((previous) => ({ ...previous, [path]: true })); - try { - const listing = await listDirectory(path); - setDirectoryCache((previous) => ({ - ...previous, - [path]: listing, - })); - } catch (error) { - toast.error( - `读取目录失败:${error instanceof Error ? error.message : String(error)}`, + if (initialDocumentSelectionKey) { + return buildDocumentTabKey(initialDocumentSelectionKey); + } + return shouldPreferTeamTabByDefault ? "team" : "session"; + }); + const hasAutoFocusedInitialDocumentTabRef = useRef( + Boolean(initialDocumentSelectionKey), + ); + + const isKnownSelectionKey = useCallback( + (selectionKey: string | null) => { + if (!selectionKey) { + return false; + } + if (selectionKey.startsWith("workspace-file:")) { + return true; + } + return ( + entryMap.has(selectionKey) || + selectionKey === defaultPreview?.selectionKey ); - } finally { - setLoadingDirectories((previous) => ({ ...previous, [path]: false })); - } - }, []); + }, + [defaultPreview?.selectionKey, entryMap], + ); useEffect(() => { - if (!workspaceRoot?.trim() || workspaceUnavailable) { + if (!selectedKey || isKnownSelectionKey(selectedKey)) { return; } - if (directoryCache[workspaceRoot]) { + setSelectedKey(fallbackSelectionKey); + }, [fallbackSelectionKey, isKnownSelectionKey, selectedKey]); + + useEffect(() => { + const seedSelectionKeys = [ + defaultPreview?.selectionKey || null, + fallbackSelectionKey, + ].filter((value): value is string => Boolean(value)); + + if (seedSelectionKeys.length === 0) { return; } - void loadDirectory(workspaceRoot); - }, [directoryCache, loadDirectory, workspaceRoot, workspaceUnavailable]); + + setOpenDocumentTabs((previous) => { + const next = [...previous]; + let changed = false; + seedSelectionKeys.forEach((selectionKey) => { + const tabKey = buildDocumentTabKey(selectionKey); + if (!next.includes(tabKey)) { + next.push(tabKey); + changed = true; + } + }); + return changed ? next : previous; + }); + }, [defaultPreview?.selectionKey, fallbackSelectionKey]); + + useEffect(() => { + setOpenDocumentTabs((previous) => { + const next = previous.filter((tabKey) => + isKnownSelectionKey(parseDocumentTabKey(tabKey)), + ); + return next.length === previous.length ? previous : next; + }); + }, [isKnownSelectionKey]); + + useEffect(() => { + if (activeTab !== "team" || teamView?.enabled) { + return; + } + setActiveTab(openDocumentTabs[0] || "session"); + }, [activeTab, openDocumentTabs, teamView?.enabled]); + + useEffect(() => { + if (!isDocumentTabKey(activeTab)) { + return; + } + const selectionKey = parseDocumentTabKey(activeTab); + if (!isKnownSelectionKey(selectionKey)) { + setActiveTab(openDocumentTabs[0] || "session"); + return; + } + if (selectedKey !== selectionKey) { + setSelectedKey(selectionKey); + } + }, [activeTab, isKnownSelectionKey, openDocumentTabs, selectedKey]); + + useEffect(() => { + if (hasAutoFocusedInitialDocumentTabRef.current) { + return; + } + if (!sessionView?.renderPanel || activeTab !== "session") { + return; + } + const initialDocumentTab = openDocumentTabs[0]; + if (!initialDocumentTab) { + return; + } + hasAutoFocusedInitialDocumentTabRef.current = true; + setActiveTab(initialDocumentTab); + }, [activeTab, openDocumentTabs, sessionView?.renderPanel]); useEffect(() => { const node = shellRef.current; @@ -642,7 +1139,6 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ return; } setIsStackedLayout(width < STACKED_LAYOUT_BREAKPOINT); - setShellWidth(width); }; const fallbackWidth = @@ -655,8 +1151,8 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ return; } - const observer = new ResizeObserver((entries) => { - const contentRect = entries[0]?.contentRect; + const observer = new ResizeObserver((observerEntries) => { + const contentRect = observerEntries[0]?.contentRect; const nextWidth = contentRect?.width || node.getBoundingClientRect().width || @@ -670,66 +1166,36 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ }; }, []); - const shouldPinTeamWorkbenchPanel = - activeTab === "team" && - teamView?.enabled === true && - teamView.preferFixedPanel === true && - shellWidth >= 860; - const usesStackedWorkbenchLayout = - isStackedLayout && !shouldPinTeamWorkbenchPanel; - useEffect(() => { - onLayoutModeChange?.(usesStackedWorkbenchLayout ? "stacked" : "split"); - }, [onLayoutModeChange, usesStackedWorkbenchLayout]); + onLayoutModeChange?.(isStackedLayout ? "stacked" : "split"); + }, [isStackedLayout, onLayoutModeChange]); - useEffect(() => { - if (usesStackedWorkbenchLayout) { - setStackedWorkbenchOpen(false); - } - }, [usesStackedWorkbenchLayout]); - - useEffect(() => { - if (!usesStackedWorkbenchLayout) { - return; - } - - return onCompactRightPanelOpen((detail) => { - if (detail.source !== "workbench") { - setStackedWorkbenchOpen(false); + const loadDirectory = useCallback( + async (path: string) => { + if (!path.trim()) { + return; } - }); - }, [usesStackedWorkbenchLayout]); - - const stackedWorkbenchMetrics = useMemo( - () => resolveStackedWorkbenchMetrics(shellWidth), - [shellWidth], + setLoadingDirectories((previous) => ({ ...previous, [path]: true })); + try { + const listing = filterWorkspaceDirectoryListing( + await listDirectory(path), + workspaceRoot, + ); + setDirectoryCache((previous) => ({ + ...previous, + [path]: listing, + })); + } catch (error) { + toast.error( + `读取目录失败:${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + setLoadingDirectories((previous) => ({ ...previous, [path]: false })); + } + }, + [workspaceRoot], ); - useEffect(() => { - setStackedWorkbenchWidth((previous) => - previous == null - ? stackedWorkbenchMetrics.defaultWidth - : clamp( - previous, - stackedWorkbenchMetrics.minWidth, - stackedWorkbenchMetrics.maxWidth, - ), - ); - }, [stackedWorkbenchMetrics]); - - useEffect(() => { - return () => { - stackedResizeCleanupRef.current?.(); - stackedResizeCleanupRef.current = null; - }; - }, []); - - useEffect(() => { - if (!availableTabs.some((tab) => tab.key === activeTab)) { - setActiveTab(availableTabs[0]?.key || "artifacts"); - } - }, [activeTab, availableTabs]); - const teamAutoFocusTokenRef = useRef( teamView?.autoFocusToken, ); @@ -745,34 +1211,89 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ teamAutoFocusTokenRef.current = teamView.autoFocusToken; setActiveTab("team"); - setCollapsed(false); - if (usesStackedWorkbenchLayout) { - setStackedWorkbenchOpen(true); - emitCompactRightPanelOpen({ source: "workbench" }); - } - }, [teamView?.autoFocusToken, teamView?.enabled, usesStackedWorkbenchLayout]); + }, [teamView?.autoFocusToken, teamView?.enabled]); + + const handleOpenDocumentSelection = useCallback((selectionKey: string) => { + setSelectedKey(selectionKey); + const tabKey = buildDocumentTabKey(selectionKey); + setOpenDocumentTabs((previous) => + previous.includes(tabKey) ? previous : [...previous, tabKey], + ); + setActiveTab(tabKey); + }, []); + + const handleCloseDocumentTab = useCallback( + (tabKey: `document:${string}`) => { + const selectionKey = parseDocumentTabKey(tabKey); + setOpenDocumentTabs((previous) => + previous.filter((currentTabKey) => currentTabKey !== tabKey), + ); + + if (selectedKey === selectionKey) { + setSelectedKey(fallbackSelectionKey); + } + + if (activeTab === tabKey) { + const fallbackTab = + openDocumentTabs.find((currentTabKey) => currentTabKey !== tabKey) || + (teamView?.enabled && shouldPreferTeamTabByDefault + ? "team" + : "session"); + setActiveTab(fallbackTab as CanvasWorkbenchTab); + } + }, + [ + activeTab, + fallbackSelectionKey, + openDocumentTabs, + selectedKey, + shouldPreferTeamTabByDefault, + teamView?.enabled, + ], + ); const handleToggleDirectory = useCallback( (path: string) => { - setExpandedDirectories((previous) => { - const nextExpanded = !previous[path]; - return { - ...previous, - [path]: nextExpanded, - }; - }); - if (!directoryCache[path]) { + const willExpand = !expandedDirectories[path]; + setExpandedDirectories((previous) => ({ + ...previous, + [path]: willExpand, + })); + if (willExpand) { void loadDirectory(path); } }, - [directoryCache, loadDirectory], + [expandedDirectories, loadDirectory], + ); + + const refreshDirectorySubtree = useCallback( + async (rootPath: string) => { + const normalizedRootPath = normalizePath(rootPath.trim()); + if (!normalizedRootPath) { + return; + } + + const expandedDescendants = Object.entries(expandedDirectories) + .filter( + ([path, expanded]) => + expanded && + normalizePath(path).startsWith(`${normalizedRootPath}/`), + ) + .map(([path]) => path); + + await Promise.all([ + loadDirectory(rootPath), + ...expandedDescendants.map((path) => loadDirectory(path)), + ]); + }, + [expandedDirectories, loadDirectory], ); const handleSelectWorkspaceFile = useCallback( async (path: string) => { const title = extractFileNameFromPath(path); const selectionKey = `workspace-file:${path}`; - setSelectedKey(selectionKey); + handleOpenDocumentSelection(selectionKey); setWorkspaceFileSelections((previous) => ({ ...previous, [selectionKey]: { @@ -822,16 +1343,118 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ }; }); }, - [loadFilePreview], + [handleOpenDocumentSelection, loadFilePreview], ); - const effectiveKey = selectedKey || fallbackEntryKey; - const selectedEntry = effectiveKey - ? entryMap.get(effectiveKey) || null - : null; - const selectedWorkspaceFile = effectiveKey?.startsWith("workspace-file:") - ? workspaceFileSelections[effectiveKey] || null - : null; + const documentSelectionKey = useMemo(() => { + if (isDocumentTabKey(activeTab)) { + return parseDocumentTabKey(activeTab); + } + return selectedKey || fallbackSelectionKey; + }, [activeTab, fallbackSelectionKey, selectedKey]); + + const documentContext = useMemo( + () => + resolveSelectionContext({ + selectionKey: documentSelectionKey, + defaultPreview, + entryMap, + workspaceFileSelections, + canvasState, + artifacts, + workspaceRoot, + }), + [ + artifacts, + canvasState, + defaultPreview, + documentSelectionKey, + entryMap, + workspaceFileSelections, + workspaceRoot, + ], + ); + + const sessionContext = useMemo(() => { + if (defaultPreview?.content.trim()) { + return buildDefaultPreviewSelection(defaultPreview); + } + return documentContext; + }, [defaultPreview, documentContext]); + + const workspacePanelRootPath = useMemo( + () => + resolveSavedContentBundleRoot( + workspaceRoot, + documentContext?.selectionPath || sessionContext?.selectionPath, + ) || workspaceRoot || null, + [ + documentContext?.selectionPath, + sessionContext?.selectionPath, + workspaceRoot, + ], + ); + + const workspacePanelDisplayPath = useMemo( + () => + resolveWorkspacePanelDisplayPath(workspaceRoot, workspacePanelRootPath), + [workspacePanelRootPath, workspaceRoot], + ); + + useEffect(() => { + if (!workspacePanelRootPath?.trim() || workspaceUnavailable) { + return; + } + if (directoryCache[workspacePanelRootPath]) { + return; + } + void loadDirectory(workspacePanelRootPath); + }, [ + directoryCache, + loadDirectory, + workspacePanelRootPath, + workspaceUnavailable, + ]); + + const teamTarget = useMemo(() => { + if (!teamView?.enabled) { + return null; + } + return { + kind: "team-workbench", + title: teamView.title || "Team Workbench", + }; + }, [teamView]); + + const hasCustomSessionView = Boolean(sessionView?.renderPanel); + + const activePreviewContext = + activeTab === "session" + ? hasCustomSessionView + ? null + : sessionContext + : isDocumentTabKey(activeTab) + ? documentContext + : null; + + const activeSelectionPath = activePreviewContext?.selectionPath; + const activeContent = activePreviewContext?.content || ""; + + const documentDiffLines = useMemo( + () => + documentContext && documentContext.previousContent !== null + ? buildCanvasWorkbenchDiff( + documentContext.previousContent, + documentContext.content, + ) + : [], + [documentContext], + ); + + useEffect(() => { + setDocumentPreviewMode("preview"); + }, [documentSelectionKey]); + const handleArtifactDocumentControllerChange = useCallback( (controller: ArtifactWorkbenchDocumentController | null) => { setArtifactDocumentController((previous) => @@ -842,414 +1465,246 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ ); useEffect(() => { - if (selectedEntry?.source === "artifact") { + if (activeTab !== "session" && !isDocumentTabKey(activeTab)) { + setArtifactDocumentController(null); return; } - setArtifactDocumentController(null); - }, [selectedEntry]); + + const previewTarget = activePreviewContext?.target; + if (previewTarget?.kind !== "artifact") { + setArtifactDocumentController(null); + } + }, [activePreviewContext?.target, activeTab]); useEffect(() => { setDocumentInspectorCollapsed(true); - }, [selectedEntry?.key, artifactDocumentController?.document?.artifactId]); + }, [documentSelectionKey, artifactDocumentController?.document?.artifactId]); - const currentTarget = useMemo(() => { - if (activeTab === "team" && teamView?.enabled) { - return { - kind: "team-workbench", - title: teamView.title || "团队工作台", - }; - } - - if (selectedWorkspaceFile) { - if (selectedWorkspaceFile.status === "loading") { - return { - kind: "loading", - title: selectedWorkspaceFile.title, - filePath: selectedWorkspaceFile.path, - absolutePath: selectedWorkspaceFile.path, - }; - } - - if (selectedWorkspaceFile.status === "binary") { - return { - kind: "unsupported", - title: selectedWorkspaceFile.title, - reason: "该文件为二进制内容,暂不支持画布文本预览。", - filePath: selectedWorkspaceFile.path, - absolutePath: selectedWorkspaceFile.path, - }; - } - - if (selectedWorkspaceFile.status === "error") { - return { - kind: "unsupported", - title: selectedWorkspaceFile.title, - reason: selectedWorkspaceFile.error || "读取文件失败", - filePath: selectedWorkspaceFile.path, - absolutePath: selectedWorkspaceFile.path, - }; - } - - return { - kind: "synthetic-artifact", - title: selectedWorkspaceFile.title, - artifact: buildSyntheticArtifact( - `canvas-workbench:file:${selectedWorkspaceFile.path}`, - selectedWorkspaceFile.path, - selectedWorkspaceFile.content || "", - ), - filePath: selectedWorkspaceFile.path, - absolutePath: selectedWorkspaceFile.path, - }; - } - - if (selectedEntry) { - if ( - defaultPreview && - selectedEntry.key === defaultPreview.selectionKey && - defaultPreview.content.trim() - ) { - return { - kind: "default-canvas", - title: defaultPreview.title, - content: defaultPreview.content, - filePath: defaultPreview.filePath, - absolutePath: defaultPreview.absolutePath, - }; - } - - if (selectedEntry.source === "artifact") { - return { - kind: "artifact", - title: selectedEntry.title, - artifact: selectedEntry.artifact, - filePath: selectedEntry.filePath, - absolutePath: selectedEntry.absolutePath, - }; - } - - if (selectedEntry.source === "document-version") { - return { - kind: "synthetic-artifact", - title: selectedEntry.title, - artifact: buildSyntheticArtifact( - `canvas-workbench:version:${selectedEntry.version.id}`, - selectedEntry.filePath || `${selectedEntry.title}.md`, - selectedEntry.version.content, - ), - filePath: selectedEntry.filePath, - absolutePath: selectedEntry.absolutePath, - }; - } - - return { - kind: "synthetic-artifact", - title: selectedEntry.title, - artifact: buildSyntheticArtifact( - `canvas-workbench:task:${selectedEntry.taskFile.id}`, - selectedEntry.filePath || selectedEntry.title, - selectedEntry.taskFile.content || "", - ), - filePath: selectedEntry.filePath, - absolutePath: selectedEntry.absolutePath, - }; - } - - if (defaultPreview) { - return { - kind: "default-canvas", - title: defaultPreview.title, - content: defaultPreview.content, - filePath: defaultPreview.filePath, - absolutePath: defaultPreview.absolutePath, - }; - } - - return { - kind: "empty", - title: "暂无可预览内容", - }; - }, [ - activeTab, - defaultPreview, - selectedEntry, - selectedWorkspaceFile, - teamView, - ]); - - const currentContent = useMemo(() => { - if (currentTarget.kind === "default-canvas") { - return currentTarget.content; - } - if ( - currentTarget.kind === "artifact" || - currentTarget.kind === "synthetic-artifact" - ) { - return currentTarget.artifact.content; - } - return ""; - }, [currentTarget]); - - const previousContent = useMemo(() => { - if (selectedWorkspaceFile?.status === "ready") { - return resolveMappedPreviousContentForPath( - selectedWorkspaceFile.path, - canvasState, - artifacts, - workspaceRoot, - ); - } - - if (!selectedEntry) { - return defaultPreview?.previousContent || null; - } - - if ( - defaultPreview && - selectedEntry.key === defaultPreview.selectionKey && - defaultPreview.content.trim() - ) { - return defaultPreview.previousContent || null; - } - - if (selectedEntry.source === "artifact") { - return resolvePreviousArtifactContent(selectedEntry.artifact, artifacts); - } - - if ( - selectedEntry.source === "document-version" && - isDocumentCanvasState(canvasState) - ) { - return resolvePreviousVersionContent( - selectedEntry.version, - canvasState.versions, - ); - } - - if (selectedEntry.absolutePath) { - return resolveMappedPreviousContentForPath( - selectedEntry.absolutePath, - canvasState, - artifacts, - workspaceRoot, - ); - } - - return null; - }, [ - artifacts, - canvasState, - defaultPreview, - selectedEntry, - selectedWorkspaceFile, - workspaceRoot, - ]); - - const diffLines = useMemo( + const documentTabs = useMemo( () => - previousContent !== null - ? buildCanvasWorkbenchDiff(previousContent, currentContent) - : [], - [currentContent, previousContent], + openDocumentTabs.map((tabKey) => { + const context = resolveSelectionContext({ + selectionKey: parseDocumentTabKey(tabKey), + defaultPreview, + entryMap, + workspaceFileSelections, + canvasState, + artifacts, + workspaceRoot, + }); + + if (context) { + return { + key: tabKey, + label: context.tabLabel, + title: context.title, + badgeLabel: context.badgeLabel, + kindLabel: context.kindLabel, + }; + } + + const selectionKey = parseDocumentTabKey(tabKey); + const fallbackLabel = selectionKey.startsWith("workspace-file:") + ? extractFileNameFromPath( + selectionKey.replace(/^workspace-file:/, ""), + ) + : selectionKey; + return { + key: tabKey, + label: fallbackLabel, + title: fallbackLabel, + badgeLabel: undefined, + kindLabel: undefined, + }; + }), + [ + artifacts, + canvasState, + defaultPreview, + entryMap, + openDocumentTabs, + workspaceFileSelections, + workspaceRoot, + ], ); - const selectionPath = - currentTarget.kind === "default-canvas" || - currentTarget.kind === "artifact" || - currentTarget.kind === "synthetic-artifact" || - currentTarget.kind === "loading" || - currentTarget.kind === "unsupported" - ? currentTarget.absolutePath || currentTarget.filePath - : undefined; + const primaryTabs = useMemo< + Array<{ + key: CanvasWorkbenchTab; + label: string; + badge?: string; + badgeTone?: "slate" | "sky" | "rose"; + }> + >( + () => [ + { + key: "session" as const, + label: sessionView?.tabLabel?.trim() || "Session · Main", + badge: sessionView?.tabBadge?.trim() || undefined, + badgeTone: sessionView?.tabBadgeTone, + }, + { + key: "workspace" as const, + label: workspaceView?.tabLabel?.trim() || "文件", + badge: + workspaceView?.tabBadge?.trim() || + (workspacePanelRootPath?.trim() && + directoryCache[workspacePanelRootPath]?.entries.length + ? String( + Math.min( + directoryCache[workspacePanelRootPath].entries.length, + 99, + ), + ) + : undefined), + badgeTone: workspaceView?.tabBadgeTone, + }, + ...(teamView?.enabled + ? [ + { + key: "team" as const, + label: + teamView.tabLabel?.trim() || teamView.title?.trim() || "team", + badge: + teamView.tabBadge?.trim() || + teamView.triggerState?.label?.trim() || + undefined, + badgeTone: + teamView.tabBadgeTone || + (teamView.triggerState?.tone === "error" + ? ("rose" as const) + : teamView.triggerState?.tone === "active" + ? ("sky" as const) + : ("slate" as const)), + }, + ] + : []), + ], + [ + directoryCache, + sessionView?.tabBadge, + sessionView?.tabBadgeTone, + sessionView?.tabLabel, + teamView, + workspacePanelRootPath, + workspaceView?.tabBadge, + workspaceView?.tabBadgeTone, + workspaceView?.tabLabel, + ], + ); const handleCopyPath = useCallback(async () => { - if (!selectionPath) { + if (!activeSelectionPath) { return; } try { if (!navigator.clipboard?.writeText) { throw new Error("当前环境不支持剪贴板写入"); } - await navigator.clipboard.writeText(selectionPath); + await navigator.clipboard.writeText(activeSelectionPath); toast.success("已复制路径"); } catch (error) { toast.error(error instanceof Error ? error.message : "复制路径失败"); } - }, [selectionPath]); + }, [activeSelectionPath]); const handleDownload = useCallback(() => { - if (!currentContent.trim()) { + if (!activeContent.trim()) { return; } const filename = extractFileNameFromPath( - selectionPath || currentTarget.title, + activeSelectionPath || activePreviewContext?.title || "canvas.md", ); - downloadText(filename, currentContent); - }, [currentContent, currentTarget.title, selectionPath]); + downloadText(filename, activeContent); + }, [activeContent, activePreviewContext?.title, activeSelectionPath]); - const renderEntriesTab = () => { - if (entries.length === 0) { - return ( -
- 暂无产物记录,可先生成一份文稿或从文件树中选择已有文件。 -
- ); + const renderDocumentInspector = () => { + if ( + !isDocumentTabKey(activeTab) || + !artifactDocumentController?.document || + !documentContext + ) { + return null; } - const showDocumentInspector = Boolean( - selectedEntry?.source === "artifact" && - artifactDocumentController?.document, - ); const documentTitle = - artifactDocumentController?.document?.title?.trim() || - selectedEntry?.title || - "当前文稿"; + artifactDocumentController.document.title?.trim() || + documentContext.title; const documentSummary = - artifactDocumentController?.document?.summary?.trim() || - "当前选中的结构化文稿已接入右侧工作台,按需展开查看概览、来源、版本与编辑。"; - const versionCount = artifactDocumentController?.versionHistory.length || 0; - const sourceCount = artifactDocumentController?.sourceLinks.length || 0; + artifactDocumentController.document.summary?.trim() || + "当前结构化文稿已接入文档检查器,可继续查看概览、来源、版本与编辑状态。"; + const versionCount = artifactDocumentController.versionHistory.length || 0; + const sourceCount = artifactDocumentController.sourceLinks.length || 0; const diffCount = - artifactDocumentController?.currentVersionDiff?.changedBlocks.length || 0; - const currentVersionLabel = artifactDocumentController?.currentVersion + artifactDocumentController.currentVersionDiff?.changedBlocks.length || 0; + const currentVersionLabel = artifactDocumentController.currentVersion ? `v${artifactDocumentController.currentVersion.versionNo}` : null; - const documentInspectorButtonLabel = documentInspectorCollapsed - ? "展开当前文稿检查器" - : "折叠当前文稿检查器"; return ( -
-
-
+
+ - ))} -
-
- - {showDocumentInspector && artifactDocumentController ? ( -
- - + ) : null} +
+

+ {documentSummary} +

+
+ 来源 {sourceCount} + 版本 {versionCount} + 差异 {diffCount} +
+
+
+ + {documentInspectorCollapsed ? "展开" : "收起"} + {documentInspectorCollapsed ? ( -
- 默认先收起概览、来源、版本与编辑,避免小屏进入时直接挤占画布空间;需要时再展开查看。 -
+ ) : ( - + )} - - ) : null} -
+ + + + {documentInspectorCollapsed ? ( +
+ 默认先收起概览、来源、版本与编辑,避免主画布被说明区挤压;需要时再展开查看。 +
+ ) : ( + + )} + ); }; @@ -1259,12 +1714,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ return null; } - return listing.entries.map((entry) => { + return sortWorkspaceListingEntries( + listing.entries, + path, + workspaceRoot, + ).map((entry) => { const rowKey = entry.path; const isDirectory = entry.isDir; const isExpanded = Boolean(expandedDirectories[entry.path]); const fileSelectionKey = `workspace-file:${entry.path}`; - const isSelected = effectiveKey === fileSelectionKey; + const isSelected = documentSelectionKey === fileSelectionKey; return (
@@ -1286,7 +1745,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ "flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left text-sm transition-colors", isSelected ? "bg-slate-100 text-slate-900" - : "text-slate-500 hover:bg-white/84 hover:text-slate-900", + : "text-slate-500 hover:bg-white hover:text-slate-900", )} style={{ paddingLeft: `${depth * 14 + 8}px` }} > @@ -1325,581 +1784,470 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ }); }; - const renderFilesTab = () => { - if (workspaceUnavailable) { - return ( -
- 当前工作区路径不可用,暂时无法浏览全部文件。 -
- ); - } - - if (!workspaceRoot?.trim()) { - return ( -
- 当前会话没有绑定可浏览的工作区目录。 -
- ); - } - - const rootListing = directoryCache[workspaceRoot]; - return ( -
-
-
-
- Workspace Root -
-
- {workspaceRoot} -
-
- -
-
- {loadingDirectories[workspaceRoot] && !rootListing ? ( -
- - 正在加载目录... -
- ) : rootListing ? ( - renderDirectoryNode(workspaceRoot) - ) : ( -
- 暂无目录内容。 -
- )} -
-
- ); - }; - - const renderChangesTab = () => { - if (!currentContent.trim()) { - return ( -
- 当前选中项没有可比较的正文内容。 -
- ); - } - - if (previousContent === null) { - return ( -
- 当前选中项没有可用的上一版本,暂时无法展示变更。 -
- ); - } - - return renderDiffState(diffLines); - }; - - const renderPreviewTab = () => { - if (currentTarget.kind === "loading") { - return ( -
- - 正在读取文件内容... -
- ); - } - - if (currentTarget.kind === "unsupported") { - return ( -
- {currentTarget.reason} -
- ); - } - - if (!currentContent.trim()) { - return ( -
- 当前选中项暂无可展示内容。 -
- ); - } - - return ( -
-
-
-            {currentContent}
-          
-
-
- ); - }; - - const renderTeamTab = () => { - if (!teamView?.enabled) { - return ( -
- 当前没有可展示的团队工作台。 -
- ); - } - - return teamView.renderPanel ? ( - teamView.renderPanel() - ) : ( -
团队工作台已启用。
- ); - }; - - const renderTabButtons = (stacked: boolean) => - stacked ? ( -
- {availableTabs.map((tab) => ( - - ))} -
- ) : ( -
- {availableTabs.map((tab) => ( - - ))} -
- ); - - const renderWorkbenchHeader = ( - stacked: boolean, - options?: { - showCollapseButton?: boolean; - }, - ) => { - const headerTitle = - activeTab === "team" - ? teamView?.title || currentTarget.title - : currentTarget.title; - - if (stacked) { - return ( - } - actions={ - <> - {activeTab !== "team" ? ( - - - - ) : null} - setStackedWorkbenchOpen(false)} - > - - - - } - > - {renderTabButtons(true)} - - ); - } - - return ( -
-
-
-
- {activeTab === "team" ? "团队工作台" : "画布工作台"} -
-
- {headerTitle} -
- {activeTab === "team" && teamView?.subtitle ? ( -
- {teamView.subtitle} -
- ) : null} - {activeTab !== "team" && selectionPath ? ( -
- {selectionPath} -
- ) : null} -
-
- {activeTab !== "team" ? ( - - ) : null} - {options?.showCollapseButton ? ( - - ) : null} -
-
- {renderTabButtons(false)} -
- ); - }; - - const renderWorkbenchFooter = (stacked: boolean) => - activeTab === "team" && teamView?.renderFooter ? ( -
- {teamView.renderFooter()} -
- ) : ( -
-
- - - -
- {previousContent !== null ? ( -
- - 已关联到上一版本,可在“变更”中查看差异。 -
- ) : null} -
- ); - - const renderActiveTab = () => - activeTab === "team" - ? renderTeamTab() - : activeTab === "artifacts" - ? renderEntriesTab() - : activeTab === "files" - ? renderFilesTab() - : activeTab === "changes" - ? renderChangesTab() - : renderPreviewTab(); - - const handleStartStackedResize = useCallback( - (event: ReactMouseEvent) => { - if (!usesStackedWorkbenchLayout || !stackedWorkbenchOpen) { - return; - } - - event.preventDefault(); - stackedResizeCleanupRef.current?.(); - - const startX = event.clientX; - const startWidth = - stackedWorkbenchWidth ?? stackedWorkbenchMetrics.defaultWidth; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const nextWidth = clamp( - startWidth + (startX - moveEvent.clientX), - stackedWorkbenchMetrics.minWidth, - stackedWorkbenchMetrics.maxWidth, - ); - setStackedWorkbenchWidth(nextWidth); - }; - - const handleMouseUp = () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - stackedResizeCleanupRef.current = null; - }; - - stackedResizeCleanupRef.current = () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - }, - [ - stackedWorkbenchMetrics.defaultWidth, - stackedWorkbenchMetrics.maxWidth, - stackedWorkbenchMetrics.minWidth, - stackedWorkbenchOpen, - stackedWorkbenchWidth, - usesStackedWorkbenchLayout, - ], - ); - - const stackedWorkbenchTrigger = - usesStackedWorkbenchLayout && !stackedWorkbenchOpen ? ( - } - label={activeTab === "team" ? "团队工作台" : "工作台"} - badgeLabel={teamView?.triggerState?.label || undefined} - badgeTone={ - teamView?.triggerState?.tone === "idle" - ? "default" - : teamView?.triggerState?.tone || "default" - } - ariaLabel="展开画布工作台" - title="工作台" - testId="canvas-workbench-trigger" - onClick={() => { - setStackedWorkbenchOpen(true); - emitCompactRightPanelOpen({ source: "workbench" }); - }} - /> - ) : undefined; - const preferFullscreenTeamPreview = - activeTab === "team" && - teamView?.enabled === true && - teamView.preferFullscreenPreview === true; - const useFramelessTeamShell = - activeTab === "team" && - teamView?.enabled === true && - !usesStackedWorkbenchLayout; - - return ( -
void; + disabled?: boolean; + icon: ReactNode; + }) => ( + + ); + + const renderTopTab = ({ + key, + label, + badge, + badgeTone, + closable = false, + }: { + key: CanvasWorkbenchTab; + label: string; + badge?: string; + badgeTone?: "slate" | "sky" | "rose"; + closable?: boolean; + }) => { + const active = activeTab === key; + const badgeClassName = + badgeTone === "rose" + ? "bg-rose-50 text-rose-700" + : badgeTone === "sky" + ? "bg-sky-50 text-sky-700" + : "bg-slate-100 text-slate-600"; + const leading = + key === "session" ? ( + + ) : key === "workspace" ? ( + + ) : key === "team" ? ( + + ) : label.match(/\.(ts|tsx|js|jsx|rs|json|yml|yaml|toml)$/i) ? ( + + ) : ( + + ); + + return ( +
+ + {leading} + + {label} + {badge ? ( + + {badge} + + ) : null} + {closable && isDocumentTabKey(key) ? ( + { + event.stopPropagation(); + handleCloseDocumentTab(key); + }} + className="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700" + > + + + ) : null} + + ); + }; - {preferFullscreenTeamPreview ? null : usesStackedWorkbenchLayout ? ( - stackedWorkbenchOpen ? ( - <> - +
+
+ {loadingDirectories[workspacePanelRootPath] && !rootListing ? ( +
+ + {workspaceView?.panelCopy?.loadingText || "正在加载目录..."} +
+ ) : rootListing ? ( + renderDirectoryNode(workspacePanelRootPath) + ) : ( +
+ {workspaceView?.panelCopy?.emptyDirectoryText || "暂无目录内容。"} +
+ )} +
+ + + ); + }; + + const renderSessionPanel = () => { + if (sessionContext) { + return ( +
+
+ {renderPreview(sessionContext.target)} +
+
+ ); + } + + if (sessionView?.renderPanel) { + return ( +
+ {sessionView.renderPanel()} +
+ ); + } + + return ( +
+
+ 当前没有可展示的会话主画布。 +
+
+ ); + }; + + const renderTeamPanel = () => { + if (!teamView?.enabled || !teamTarget) { + return ( +
+
+ {teamView?.panelCopy?.emptyText || + "当前没有可展示的 Team Workbench。"} +
+
+ ); + } + + return ( +
+
+ {renderPreview(teamTarget)} +
+ {teamView.renderPanel ? ( +
+ {teamView.renderPanel()} +
+ ) : null} + {teamView.renderFooter ? ( +
+ {teamView.renderFooter()} +
+ ) : null} +
+ ); + }; + + const renderDocumentPanel = () => { + if (!documentContext) { + return ( +
+
+ 当前标签没有对应的可展示内容。 +
+
+ ); + } + + const canShowDiff = documentContext.previousContent !== null; + const showDiff = canShowDiff && documentPreviewMode === "changes"; + + return ( +
+
+
+
+
+ + {documentContext.kindLabel} + + {documentContext.badgeLabel ? ( + {documentContext.badgeLabel} + ) : null} +
+
+ {documentContext.title} +
+ {documentContext.subtitle ? ( +
+ {documentContext.subtitle} +
+ ) : documentContext.selectionPath ? ( +
+ {documentContext.selectionPath} +
+ ) : null} +
+
- {availableTabs.map((tab) => ( + {canShowDiff ? ( - ))} + ) : null}
+
+ {canShowDiff ? ( +
+ + 已关联上一版本,可直接在当前文件标签内切换查看 diff。 +
+ ) : null} +
+ + {renderDocumentInspector()} + +
+ {showDiff ? ( + renderDiffState(documentDiffLines) ) : ( - <> - {renderWorkbenchHeader(false, { - showCollapseButton: true, +
+ {renderPreview(documentContext.target, { + onArtifactDocumentControllerChange: + handleArtifactDocumentControllerChange, })} -
- {renderActiveTab()} -
- {renderWorkbenchFooter(false)} - +
)} - +
+
+ ); + }; + + return ( +
+ > +
+
+
+
+ {primaryTabs.map((tab) => + renderTopTab({ + key: tab.key, + label: tab.label, + badge: tab.badge, + badgeTone: tab.badgeTone, + }), + )} + + {documentTabs.length > 0 ? ( +
+ ) : null} + + {documentTabs.map((tab) => + renderTopTab({ + key: tab.key, + label: tab.label, + badge: tab.badgeLabel || tab.kindLabel, + closable: true, + }), + )} +
+
+ + {activeTab !== "team" && activePreviewContext ? ( +
+ {renderHeaderActionButton({ + label: "复制当前路径", + disabled: !activeSelectionPath, + onClick: () => { + void handleCopyPath(); + }, + icon: , + })} + {renderHeaderActionButton({ + label: "定位当前文件", + disabled: !activeSelectionPath, + onClick: () => { + if (activeSelectionPath) { + void onRevealPath(activeSelectionPath); + } + }, + icon: , + })} + {renderHeaderActionButton({ + label: "系统打开当前文件", + disabled: !activeSelectionPath, + onClick: () => { + if (activeSelectionPath) { + void onOpenPath(activeSelectionPath); + } + }, + icon: , + })} + {renderHeaderActionButton({ + label: "下载当前画布项", + disabled: !activeContent.trim(), + onClick: handleDownload, + icon: , + })} +
+ ) : null} +
+
+ +
+ {activeTab === "workspace" + ? renderWorkspacePanel() + : activeTab === "team" + ? renderTeamPanel() + : activeTab === "session" + ? renderSessionPanel() + : renderDocumentPanel()} +
+
); }); diff --git a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx index 26083d2f2..f91ad2ba8 100644 --- a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx +++ b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx @@ -12,6 +12,8 @@ const { mockParseAgentEvent, mockSafeListen, mockToast, + mockLoadConfiguredProviders, + mockLoadProviderModels, mockUseConfiguredProviders, mockUseProviderModels, mockProviderPoolGetOverview, @@ -32,6 +34,8 @@ const { info: vi.fn(), warning: vi.fn(), }, + mockLoadConfiguredProviders: vi.fn(), + mockLoadProviderModels: vi.fn(), mockUseConfiguredProviders: vi.fn(), mockUseProviderModels: vi.fn(), mockProviderPoolGetOverview: vi.fn(), @@ -82,6 +86,7 @@ vi.mock("sonner", () => ({ })); vi.mock("@/hooks/useConfiguredProviders", () => ({ + loadConfiguredProviders: mockLoadConfiguredProviders, useConfiguredProviders: mockUseConfiguredProviders, findConfiguredProviderBySelection: ( providers: Array<{ key: string; providerId?: string }>, @@ -110,6 +115,7 @@ vi.mock("@/hooks/useConfiguredProviders", () => ({ })); vi.mock("@/hooks/useProviderModels", () => ({ + loadProviderModels: mockLoadProviderModels, useProviderModels: mockUseProviderModels, })); @@ -136,12 +142,16 @@ vi.mock("@/lib/api/channelsRuntime", () => ({ import { useAsterAgentChat } from "../hooks/useAsterAgentChat"; import { ChatModelSelector } from "./ChatModelSelector"; -interface MountedHarness { +interface MountedRoot { container: HTMLDivElement; root: Root; } -const mountedRoots: MountedHarness[] = []; +interface MountedHarness extends MountedRoot { + getChat: () => ReturnType; +} + +const mountedRoots: MountedRoot[] = []; interface MountOptions { onManageProviders?: () => void; @@ -183,35 +193,51 @@ function createModel(id: string, providerId: string) { }; } +function createConfiguredProviderFixtures() { + return [ + { key: "gemini", label: "Gemini", registryId: "gemini", type: "gemini" }, + { + key: "deepseek", + label: "DeepSeek", + registryId: "deepseek", + type: "deepseek", + }, + ]; +} + +function createProviderModelsFixture(providerKey?: string | null) { + if (providerKey === "gemini") { + return [ + createModel("gemini-2.5-pro", "gemini"), + createModel("gemini-2.5-flash", "gemini"), + ]; + } + + if (providerKey === "deepseek") { + return [ + createModel("deepseek-chat", "deepseek"), + createModel("deepseek-reasoner", "deepseek"), + ]; + } + + return []; +} + function mount( workspaceId: string, options: MountOptions = {}, -): HTMLDivElement { +): MountedHarness { const { onManageProviders, chatModelSelectorProps } = options; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + let chatValue: ReturnType | null = null; function TestComponent() { const chat = useAsterAgentChat({ workspaceId }); + chatValue = chat; return (
- - { + if (!chatValue) { + throw new Error("chat 尚未初始化"); + } + return chatValue; + }, + }; } function getComboboxTrigger(container: HTMLElement): HTMLButtonElement { @@ -303,6 +325,7 @@ beforeEach(() => { mockApiKeyProvidersGetProviders.mockResolvedValue([]); mockEmitProviderDataChanged.mockImplementation(() => {}); mockWechatChannelSetRuntimeModel.mockResolvedValue(undefined); + const configuredProviders = createConfiguredProviderFixtures(); const createdAt = Math.floor(Date.now() / 1000); mockListAgentRuntimeSessions.mockResolvedValue([ @@ -333,33 +356,14 @@ beforeEach(() => { })); mockUseConfiguredProviders.mockReturnValue({ - providers: [ - { key: "gemini", label: "Gemini", registryId: "gemini", type: "gemini" }, - { - key: "deepseek", - label: "DeepSeek", - registryId: "deepseek", - type: "deepseek", - }, - ], + providers: configuredProviders, loading: false, }); + mockLoadConfiguredProviders.mockResolvedValue(configuredProviders); mockUseProviderModels.mockImplementation( (selectedProvider: { key: string } | null) => { - const key = selectedProvider?.key; - const models = - key === "gemini" - ? [ - createModel("gemini-2.5-pro", "gemini"), - createModel("gemini-2.5-flash", "gemini"), - ] - : key === "deepseek" - ? [ - createModel("deepseek-chat", "deepseek"), - createModel("deepseek-reasoner", "deepseek"), - ] - : []; + const models = createProviderModelsFixture(selectedProvider?.key); return { modelIds: models.map((item) => item.id), @@ -369,6 +373,10 @@ beforeEach(() => { }; }, ); + mockLoadProviderModels.mockImplementation( + async (selectedProvider?: { key?: string | null } | null) => + createProviderModelsFixture(selectedProvider?.key), + ); }); afterEach(() => { @@ -387,15 +395,17 @@ afterEach(() => { describe("ChatModelSelector + useAsterAgentChat 集成", () => { it("通过 UI 选择模型后切换话题再切回,应恢复会话模型", async () => { const workspaceId = "ws-model-selector-integration"; - const container = mount(workspaceId); + const harness = mount(workspaceId); + const { container } = harness; await flushEffects(); await flushEffects(); await act(async () => { - getButtonByTestId(container, "switch-topic-a").click(); + await harness.getChat().switchTopic("topic-a"); }); await flushEffects(); + await flushEffects(); await act(async () => { getComboboxTrigger(container).click(); @@ -413,9 +423,10 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { await flushEffects(); await act(async () => { - getButtonByTestId(container, "switch-topic-b").click(); + await harness.getChat().switchTopic("topic-b"); }); await flushEffects(); + await flushEffects(); await act(async () => { getComboboxTrigger(container).click(); @@ -433,9 +444,10 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { await flushEffects(); await act(async () => { - getButtonByTestId(container, "switch-topic-a").click(); + await harness.getChat().switchTopic("topic-a"); }); await flushEffects(); + await flushEffects(); const currentModel = container.querySelector( '[data-testid="current-model"]', @@ -467,6 +479,7 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { providers: [], loading: false, }); + mockLoadConfiguredProviders.mockResolvedValue([]); mockUseProviderModels.mockReturnValue({ modelIds: [], models: [], @@ -475,7 +488,7 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { }); const onManageProviders = vi.fn(); - const container = mount("ws-no-provider-guide", { onManageProviders }); + const { container } = mount("ws-no-provider-guide", { onManageProviders }); await flushEffects(); @@ -489,8 +502,41 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { expect(onManageProviders).toHaveBeenCalledTimes(1); }); + it("无 Provider 时应支持关闭配置引导", async () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [], + loading: false, + }); + mockLoadConfiguredProviders.mockResolvedValue([]); + mockUseProviderModels.mockReturnValue({ + modelIds: [], + models: [], + loading: false, + error: null, + }); + + const { container } = mount("ws-no-provider-guide-dismiss"); + + await flushEffects(); + + expect(container.textContent).toContain("工具模型未配置"); + + const dismissButton = container.querySelector( + 'button[aria-label="关闭工具模型未配置提示"]', + ) as HTMLButtonElement | null; + if (!dismissButton) { + throw new Error("未找到关闭引导按钮"); + } + + await act(async () => { + dismissButton.click(); + }); + + expect(container.textContent ?? "").not.toContain("工具模型未配置"); + }); + it("关闭后台预加载时,应在打开选择器后再加载 Provider 和模型", async () => { - const container = mount("ws-model-selector-lazy-provider-load", { + const { container } = mount("ws-model-selector-lazy-provider-load", { chatModelSelectorProps: { backgroundPreload: "disabled", }, diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index bf1c36261..33d323af8 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -249,7 +249,12 @@ describe("EmptyState", () => { 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).not.toContain("新建任务"); }); @@ -267,17 +272,17 @@ describe("EmptyState", () => { 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).toContain("复制轮播帖"); expect(container.textContent).toContain("复制视频脚本"); - expect(container.textContent).toContain("文章转 Slide 视频提纲"); - 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("文章转 Slide 视频提纲"); + expect(container.textContent).not.toContain("云端视频配音"); + expect(container.textContent).not.toContain("视频配音成其他语言"); + expect(container.textContent).not.toContain("每日趋势摘要"); + expect(container.textContent).not.toContain("账号增长跟踪"); expect(container.textContent).not.toContain("从这里开始"); expect(container.textContent).not.toContain("快速启动"); expect(container.textContent).not.toContain("生成配图"); @@ -293,17 +298,10 @@ describe("EmptyState", () => { expect(recommendationTitles).toEqual([ "网页研究简报", "内容主稿生成", - "前端概念方案", "演示提纲草案", "网页登录与采集", - "多代理拆任务", "复制轮播帖", "复制视频脚本", - "文章转 Slide 视频提纲", - "云端视频配音", - "视频配音成其他语言", - "每日趋势摘要", - "账号增长跟踪", ]); }); @@ -329,6 +327,10 @@ describe("EmptyState", () => { expect(container.textContent).toContain(label); } + expect(container.textContent).toContain("重复任务可持续推进"); + expect(container.textContent).toContain("复杂任务可拆分并行推进"); + expect(container.textContent).toContain("网页登录与网页执行"); + for (const alt of expectedAlts) { const image = container.querySelector(`img[alt="${alt}"]`); expect(image).toBeTruthy(); @@ -445,36 +447,7 @@ describe("EmptyState", () => { ); }); - it("点击多代理拆任务应开启多代理偏好并写入起始动作", async () => { - const setInput = vi.fn<(value: string) => void>(); - const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>(); - const container = renderEmptyState({ - activeTheme: "general", - setInput, - onSubagentEnabledChange, - subagentEnabled: false, - }); - - await act(async () => { - await Promise.resolve(); - }); - - const card = container.querySelector( - '[data-testid="entry-recommended-team-breakdown"]', - ) as HTMLDivElement | null; - expect(card).toBeTruthy(); - - act(() => { - card?.click(); - }); - - expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); - expect(setInput).toHaveBeenCalledWith( - "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", - ); - }); - - it("指定 service skill 即使未从运行时目录注入,也应从 seeded 目录拼接到推荐区尾部", async () => { + it("指定 service skill 即使未从运行时目录注入,也应从 seeded 目录拼接到首页精选区尾部", async () => { const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); const container = renderEmptyState({ activeTheme: "general", @@ -486,11 +459,11 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("每日趋势摘要"); + expect(container.textContent).toContain("复制视频脚本"); expect(container.textContent).not.toContain("GitHub 仓库线索检索"); const card = container.querySelector( - '[data-testid="entry-service-skill-daily-trend-briefing"]', + '[data-testid="entry-service-skill-short-video-script-replication"]', ) as HTMLButtonElement | null; expect(card).toBeTruthy(); @@ -500,8 +473,8 @@ describe("EmptyState", () => { expect(onSelectServiceSkill).toHaveBeenCalledWith( expect.objectContaining({ - id: "daily-trend-briefing", - title: "每日趋势摘要", + id: "short-video-script-replication", + title: "复制视频脚本", }), ); }); @@ -736,9 +709,10 @@ describe("EmptyState", () => { ); }); - it("点击地球按钮应切换联网搜索开关", async () => { + it("点击高级设置中的地球按钮应切换联网搜索开关", async () => { const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); const container = renderEmptyState({ + activeTheme: "general", webSearchEnabled: false, onWebSearchEnabledChange, }); @@ -746,18 +720,133 @@ describe("EmptyState", () => { await Promise.resolve(); }); + const advancedToggle = container.querySelector( + '[data-testid="empty-state-advanced-toggle"]', + ) as HTMLButtonElement | null; + expect(advancedToggle).toBeTruthy(); const globeToggle = container.querySelector( 'button[title="联网搜索已关闭"]', ) as HTMLButtonElement | null; - expect(globeToggle).toBeTruthy(); + expect(globeToggle).toBeNull(); act(() => { - globeToggle?.click(); + advancedToggle?.click(); + }); + + const expandedGlobeToggle = container.querySelector( + 'button[title="联网搜索已关闭"]', + ) as HTMLButtonElement | null; + expect(expandedGlobeToggle).toBeTruthy(); + + act(() => { + expandedGlobeToggle?.click(); }); expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true); }); + it("通用主题默认只保留最小主路径,展开高级设置后才显示进阶控制", async () => { + const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>(); + const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); + const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>(); + const setExecutionStrategy = + vi.fn<(strategy: "react" | "code_orchestrated" | "auto") => void>(); + const setAccessMode = + vi.fn<(mode: "read-only" | "current" | "full-access") => void>(); + const container = renderEmptyState({ + activeTheme: "general", + thinkingEnabled: false, + onThinkingEnabledChange, + webSearchEnabled: false, + onWebSearchEnabledChange, + subagentEnabled: false, + onSubagentEnabledChange, + executionStrategy: "react", + setExecutionStrategy, + accessMode: "current", + setAccessMode, + }); + await act(async () => { + await Promise.resolve(); + }); + + const attachButton = container.querySelector( + 'button[title="添加图片"]', + ) as HTMLButtonElement | null; + expect(attachButton).toBeTruthy(); + + const advancedToggle = container.querySelector( + '[data-testid="empty-state-advanced-toggle"]', + ) as HTMLButtonElement | null; + expect(advancedToggle).toBeTruthy(); + + 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('[data-testid="inputbar-access-mode-select"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="chat-model-selector"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("通用任务上下文"); + + act(() => { + advancedToggle?.click(); + }); + + const thinkingButton = container.querySelector( + 'button[title="深度思考已关闭"]', + ) as HTMLButtonElement | null; + expect(thinkingButton).toBeTruthy(); + const globeButton = container.querySelector( + 'button[title="联网搜索已关闭"]', + ) as HTMLButtonElement | null; + expect(globeButton).toBeTruthy(); + const planButton = container.querySelector( + '[data-testid="inputbar-plan-toggle"]', + ) as HTMLButtonElement | null; + expect(planButton).toBeTruthy(); + const subagentButton = container.querySelector( + 'button[title="多代理偏好已关闭"]', + ) as HTMLButtonElement | null; + expect(subagentButton).toBeTruthy(); + const accessModeSelect = container.querySelector( + '[data-testid="inputbar-access-mode-select"]', + ) as HTMLSelectElement | null; + expect(accessModeSelect).toBeTruthy(); + expect( + container.querySelector('[data-testid="chat-model-selector"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("通用任务上下文"); + + act(() => { + thinkingButton?.click(); + }); + act(() => { + globeButton?.click(); + }); + act(() => { + planButton?.click(); + }); + act(() => { + subagentButton?.click(); + }); + act(() => { + accessModeSelect!.value = "full-access"; + accessModeSelect?.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onThinkingEnabledChange).toHaveBeenCalledWith(true); + expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true); + expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated"); + expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); + expect(setAccessMode).toHaveBeenCalledWith("full-access"); + }); + it("通用首页发送时不应自动注入任何历史默认 skill", async () => { const onSend = vi.fn< @@ -882,57 +971,6 @@ describe("EmptyState", () => { ); }); - it("通用主题工具栏应包含附件、思考、Plan 与多代理开关", async () => { - const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>(); - const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>(); - const setExecutionStrategy = - vi.fn<(strategy: "react" | "code_orchestrated" | "auto") => void>(); - const container = renderEmptyState({ - activeTheme: "general", - thinkingEnabled: false, - onThinkingEnabledChange, - subagentEnabled: false, - onSubagentEnabledChange, - executionStrategy: "react", - setExecutionStrategy, - }); - await act(async () => { - await Promise.resolve(); - }); - - const attachButton = container.querySelector( - 'button[title="添加图片"]', - ) as HTMLButtonElement | null; - expect(attachButton).toBeTruthy(); - - const thinkingButton = container.querySelector( - 'button[title="深度思考已关闭"]', - ) as HTMLButtonElement | null; - expect(thinkingButton).toBeTruthy(); - const planButton = container.querySelector( - '[data-testid="inputbar-plan-toggle"]', - ) as HTMLButtonElement | null; - expect(planButton).toBeTruthy(); - const subagentButton = container.querySelector( - 'button[title="多代理偏好已关闭"]', - ) as HTMLButtonElement | null; - expect(subagentButton).toBeTruthy(); - - act(() => { - thinkingButton?.click(); - }); - act(() => { - planButton?.click(); - }); - act(() => { - subagentButton?.click(); - }); - - expect(onThinkingEnabledChange).toHaveBeenCalledWith(true); - expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated"); - expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); - }); - it("通用主题应提供浏览器协助入口并触发启动回调", async () => { const onLaunchBrowserAssist = vi.fn(); const container = renderEmptyState({ diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 39e016352..cb718fa62 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -257,14 +257,15 @@ type RecommendationShelfItem = }; const GENERAL_CATEGORY_LABEL = "通用对话"; +const FEATURED_HOME_RECOMMENDED_SOLUTION_IDS = [ + "web-research-brief", + "social-post-starter", + "slide-outline", + "browser-assist-task", +] as const; const FEATURED_HOME_SERVICE_SKILL_IDS = [ "carousel-post-replication", "short-video-script-replication", - "article-to-slide-video-outline", - "cloud-video-dubbing", - "video-dubbing-language", - "daily-trend-briefing", - "account-performance-tracking", ] as const; function resolveFeaturedSkillExecutionKind( @@ -339,7 +340,7 @@ const THEME_WORKBENCH_COPY: Record< title: "", description: "说一句目标,剩下的交给 Lime。", supportingDescription: - "文案、图片、视频、搜索、整理与网页执行可以围绕同一目标持续推进;成功做法会沉淀成技能,偏好、参考与成果会逐渐沉淀成个人资产。", + "文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。", }, }; @@ -738,7 +739,7 @@ export const EmptyState: React.FC = ({ title: "技能", value: skillSummaryLabel, description: - "把跑通过的提示、步骤和工具组合沉淀下来,下次遇到同类任务可以直接复用。", + "把常用做法沉淀下来,下次遇到同类任务可以直接复用。", icon: , imageSrc: capabilitySkillsPlaceholder, imageAlt: "技能能力卡占位图", @@ -748,9 +749,9 @@ export const EmptyState: React.FC = ({ key: "automation", eyebrow: "支撑能力", title: "自动化", - value: planEnabled ? "当前会按步骤推进" : "重复流程可持续跑起来", + value: planEnabled ? "当前会按步骤推进" : "重复任务可持续推进", description: - "适合长链路处理、批量任务和持续产出,让重复动作不再每次都从头手动重做。", + "适合批量任务和重复流程,让常做的事情持续推进,不再每次从头开始。", icon: , imageSrc: capabilityAutomationsPlaceholder, imageAlt: "自动化能力卡占位图", @@ -761,10 +762,10 @@ export const EmptyState: React.FC = ({ eyebrow: "支撑能力", title: "多代理", value: subagentEnabled - ? "当前任务支持并行协作" - : "复杂任务可拆成并行分工", + ? "当前任务支持协作分工" + : "复杂任务可拆分并行推进", description: - "当研究、方案和执行需要同时推进时,可把任务拆给多个代理并行处理,再统一回收结论。", + "当一个目标需要调研、方案和执行同时推进时,可以拆成协作分工后统一回收结论。", icon: , imageSrc: capabilityAgentTeamsPlaceholder, imageAlt: "多代理协作能力卡占位图", @@ -776,9 +777,9 @@ export const EmptyState: React.FC = ({ key: "browser", eyebrow: "支撑能力", title: "浏览器接入", - value: browserAssistLoading ? "正在检查连接状态" : "CDP / 浏览器插件复用", + value: browserAssistLoading ? "正在检查连接状态" : "网页登录与网页执行", description: - "登录、验证和网页动作可直接复用 CDP 或浏览器插件连接,不必再切到单独工作台。", + "登录、验证和网页操作可以直接在当前任务里继续,不必再切到单独工作台。", icon: , imageSrc: capabilityBrowserAssistPlaceholder, imageAlt: "浏览器接入能力卡占位图", @@ -805,6 +806,11 @@ export const EmptyState: React.FC = ({ const recommendationShelfItems = useMemo(() => { const solutionRecommendations = entryRecommendedSolutions + .filter((solution) => + FEATURED_HOME_RECOMMENDED_SOLUTION_IDS.includes( + solution.id as (typeof FEATURED_HOME_RECOMMENDED_SOLUTION_IDS)[number], + ), + ) .map((solution) => ({ kind: "solution" as const, key: solution.id, diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index f57aaf0cf..9da97a504 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -234,8 +234,22 @@ function renderStatefulPanel( return container; } +function expandAdvancedControls(container: HTMLDivElement) { + const toggleButton = container.querySelector( + '[data-testid="empty-state-advanced-toggle"]', + ) as HTMLButtonElement | null; + + expect(toggleButton).toBeTruthy(); + + act(() => { + toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + return toggleButton; +} + describe("EmptyStateComposerPanel", () => { - it("首页空态输入区应保留技能下拉,但与 @ 面板共用同一技能入口", () => { + it("首页空态输入区默认隐藏技能入口,展开高级设置后与 @ 面板共用同一技能入口", () => { const container = renderPanel({ isGeneralTheme: true, skillSelection: createSkillSelection({ @@ -255,6 +269,12 @@ describe("EmptyStateComposerPanel", () => { expect( container.querySelector('[data-testid="empty-state-character-mention"]'), ).toBeTruthy(); + expect( + container.querySelector('[data-testid="empty-state-skill-selector"]'), + ).toBeNull(); + + expandAdvancedControls(container); + expect( container.querySelector('[data-testid="empty-state-skill-selector"]'), ).toBeTruthy(); @@ -412,12 +432,18 @@ describe("EmptyStateComposerPanel", () => { subagentEnabled: true, }); + expect( + container.querySelector('[data-testid="empty-state-team-selector"]'), + ).toBeNull(); + + expandAdvancedControls(container); + expect( container.querySelector('[data-testid="empty-state-team-selector"]'), ).toBeTruthy(); }); - it("未开启 Team mode 时应只保留图标开关,不再显示重复的文字入口", () => { + it("未开启 Team mode 时默认不暴露多代理开关,只保留高级设置入口", () => { const container = renderPanel({ isGeneralTheme: true, subagentEnabled: false, @@ -431,21 +457,27 @@ describe("EmptyStateComposerPanel", () => { '[data-testid="empty-state-team-mode-enable-button"]', ), ).toBeNull(); - - const toggleButton = container.querySelector( - 'button[title="多代理偏好已关闭"]', - ) as HTMLButtonElement | null; - - expect(toggleButton).toBeTruthy(); + expect( + container.querySelector('button[title="多代理偏好已关闭"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="empty-state-advanced-toggle"]'), + ).toBeTruthy(); }); - it("应通过 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => { + it("应通过高级设置中的 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => { const setExecutionStrategy = vi.fn(); const container = renderPanel({ executionStrategy: "react", setExecutionStrategy, }); + expect( + container.querySelector('[data-testid="inputbar-plan-toggle"]'), + ).toBeNull(); + + expandAdvancedControls(container); + const planToggle = container.querySelector( '[data-testid="inputbar-plan-toggle"]', ) as HTMLButtonElement | null; @@ -479,6 +511,9 @@ describe("EmptyStateComposerPanel", () => { expect(enableButton).toBeNull(); expect( container.querySelector('button[title="多代理偏好已关闭"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="empty-state-advanced-toggle"]'), ).toBeTruthy(); }); @@ -499,6 +534,8 @@ describe("EmptyStateComposerPanel", () => { it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => { const container = renderStatefulPanel(); + expandAdvancedControls(container); + const enableButton = container.querySelector( 'button[title="多代理偏好已关闭"]', ) as HTMLButtonElement | null; @@ -530,6 +567,8 @@ describe("EmptyStateComposerPanel", () => { true, ); + expandAdvancedControls(container); + expect( container.querySelector('[data-testid="empty-state-team-selector"]'), ).toBeTruthy(); @@ -574,19 +613,44 @@ describe("EmptyStateComposerPanel", () => { ).toBeNull(); expect( container.querySelector('button[title="多代理偏好已关闭"]'), - ).toBeTruthy(); + ).toBeNull(); expect(enableButton).toBeTruthy(); expect(enableButton?.textContent).toContain("启用 Team"); expect(container.textContent).toContain("当前任务更适合 Team 协作"); }); - it("应渲染权限模式选择并透传切换", () => { + it("折叠态应保留当前模型轻提示,展开高级设置后再允许修改", () => { + const container = renderPanel({ + providerType: "claude", + model: "claude-sonnet-4-5", + }); + + expect(container.textContent).toContain("当前模型"); + expect(container.textContent).toContain("claude-sonnet-4-5"); + expect( + container.querySelector('[data-testid="empty-state-model-selector"]'), + ).toBeNull(); + + expandAdvancedControls(container); + + expect( + container.querySelector('[data-testid="empty-state-model-selector"]'), + ).toBeTruthy(); + }); + + it("应在高级设置中渲染权限模式选择并透传切换", () => { const setAccessMode = vi.fn(); const container = renderPanel({ accessMode: "current", setAccessMode, }); + expect( + container.querySelector('[data-testid="inputbar-access-mode-select"]'), + ).toBeNull(); + + expandAdvancedControls(container); + const select = container.querySelector( '[data-testid="inputbar-access-mode-select"]', ) as HTMLSelectElement | null; diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 25c78cab8..9c574023d 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useRef, useState } from "react"; -import { Globe } from "lucide-react"; +import { ChevronDown, ChevronUp, Globe, Settings2 } from "lucide-react"; import { Select, SelectContent, @@ -26,12 +26,19 @@ import { EMPTY_STATE_PASSIVE_BADGE_CLASSNAME, EMPTY_STATE_SELECT_TRIGGER_CLASSNAME, } from "./emptyStateSurfaceTokens"; +import { + MetaToggleButton, + MetaToggleCheck, + MetaToggleGlyph, + MetaToggleLabel, +} from "./Inputbar/styles"; import { getTeamSuggestion } from "../utils/teamSuggestion"; import { buildSkillSelectionBindings, type SkillSelectionProps, } from "../skill-selection/skillSelectionBindings"; import type { AgentAccessMode } from "../hooks/agentChatStorage"; +import { getProviderLabel } from "@/lib/constants/providerMappings"; interface EmptyStateComposerPanelProps { input: string; @@ -122,6 +129,7 @@ export function EmptyStateComposerPanel({ const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState< number | null >(null); + const [showAdvancedControls, setShowAdvancedControls] = useState(false); const activeSkill = skillSelection.activeSkill ?? null; const clearActiveSkill = skillSelection.onClearSkill; const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } = @@ -203,94 +211,157 @@ export function EmptyStateComposerPanel({ ) : undefined; const shouldShowThemeSpecificExtra = showCreationModeSelector; - const shouldShowModelExtra = Boolean(providerType?.trim() && model?.trim()); - const shouldShowLeftExtra = + const trimmedProviderType = providerType.trim(); + const trimmedModel = model.trim(); + const hasConfiguredModel = Boolean(trimmedProviderType && trimmedModel); + const shouldShowModelControls = true; + const currentModelSummary = hasConfiguredModel + ? `${getProviderLabel(trimmedProviderType)} / ${trimmedModel}` + : null; + const hasHighlightedAdvancedPreference = + thinkingEnabled || + webSearchEnabled || + subagentEnabled || + executionStrategy === "code_orchestrated" || + accessMode === "read-only" || + accessMode === "full-access"; + const shouldShowAdvancedToggle = isGeneralTheme || shouldShowTeamSelector || Boolean(setExecutionStrategy) || - shouldShowModelExtra || + shouldShowModelControls || Boolean(setAccessMode) || shouldShowThemeSpecificExtra; - const leftExtra = shouldShowLeftExtra ? ( + const leftExtra = shouldShowAdvancedToggle ? ( <> - {isGeneralTheme ? : null} - - {shouldShowTeamSelector ? ( - onSelectTeam?.(team)} + setShowAdvancedControls((previous) => !previous)} + > + - ) : null} + + + + 高级设置 + {showAdvancedControls ? ( + + ) : ( + + )} + - - - - - - - {showCreationModeSelector ? ( - - ) : null} - - {isGeneralTheme ? ( + {!showAdvancedControls && currentModelSummary ? ( - - 通用任务上下文 + 当前模型 + {trimmedModel} ) : null} + + {!showAdvancedControls && !hasConfiguredModel ? ( + + ) : null} + + {showAdvancedControls ? ( + <> + {isGeneralTheme ? : null} + + {shouldShowTeamSelector ? ( + onSelectTeam?.(team)} + /> + ) : null} + + + + + + + + {showCreationModeSelector ? ( + + ) : null} + + {isGeneralTheme ? ( + + + 通用任务上下文 + + ) : null} + + ) : null} ) : undefined; @@ -340,6 +411,7 @@ export function EmptyStateComposerPanel({ visualVariant="floating" topExtra={topExtra} leftExtra={leftExtra} + showMetaTools={showAdvancedControls} /> ); diff --git a/src/components/agent/chat/components/InlineToolProcessStep.tsx b/src/components/agent/chat/components/InlineToolProcessStep.tsx index 5755381f3..27e75f490 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; -import { ChevronDown, ExternalLink, Loader2 } from "lucide-react"; +import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { @@ -19,8 +19,9 @@ import { import { normalizeSiteToolResultSummary, resolveSiteAdapterSourceLabel, + resolveSiteSavedContentTargetRelativePath, resolveSiteProjectSourceLabel, - resolveSiteSavedContentTarget, + resolveSiteSavedContentTargetFromMetadata, } from "../utils/siteToolResultSummary"; interface InlineToolProcessStepProps { @@ -187,13 +188,13 @@ export const InlineToolProcessStep: React.FC = ({ () => normalizeToolResultImages(toolCall.result?.images, resultText) || [], [resultText, toolCall.result?.images], ); - const siteSummary = useMemo( - () => normalizeSiteToolResultSummary(toolCall.result?.metadata), + const savedSiteContentTarget = useMemo( + () => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata), [toolCall.result?.metadata], ); - const savedSiteContentTarget = useMemo( - () => resolveSiteSavedContentTarget(siteSummary), - [siteSummary], + const savedSiteContentRelativePath = useMemo( + () => resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget), + [savedSiteContentTarget], ); const siteNoticeLines = useMemo( () => buildSiteNoticeLines(toolCall), @@ -346,14 +347,27 @@ export const InlineToolProcessStep: React.FC = ({
) : null} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 9e5f496a0..5e67603e2 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { ChevronDown, ChevronUp, Settings2 } from "lucide-react"; import type { ChatInputAdapter } from "@/components/input-kit/adapters/types"; import type { Character } from "@/lib/api/memory"; import type { @@ -29,6 +30,14 @@ import type { WorkflowQuickAction, WorkflowStep, } from "../../../utils/workflowInputState"; +import { Badge } from "@/components/ui/badge"; +import { + MetaToggleButton, + MetaToggleCheck, + MetaToggleGlyph, + MetaToggleLabel, +} from "../styles"; +import { getProviderLabel } from "@/lib/constants/providerMappings"; interface InputbarComposerSectionProps { renderWorkflowGeneratingPanel: boolean; @@ -112,6 +121,7 @@ export const InputbarComposerSection: React.FC< const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState< number | null >(null); + const [showAdvancedControls, setShowAdvancedControls] = useState(false); const showSkillSelector = !isWorkspaceVariant && isGeneralResearchTheme(activeTheme); const currentPendingImages = @@ -121,6 +131,14 @@ export const InputbarComposerSection: React.FC< buildSkillSelectionBindings(skillSelection); const resolvedProviderType = inputAdapter.model?.providerType; const resolvedModel = inputAdapter.model?.model; + const trimmedProviderType = resolvedProviderType?.trim() || ""; + const trimmedModel = resolvedModel?.trim() || ""; + const shouldShowModelControls = !isWorkspaceVariant; + const hasConfiguredModel = Boolean(trimmedProviderType && trimmedModel); + const currentModelSummary = + shouldShowModelControls && hasConfiguredModel + ? `${getProviderLabel(trimmedProviderType)} / ${trimmedModel}` + : null; const resolvedSetProviderType = inputAdapter.actions.setProviderType || (() => undefined); const resolvedSetModel = inputAdapter.actions.setModel || (() => undefined); @@ -151,6 +169,113 @@ export const InputbarComposerSection: React.FC< } onToolClick(tool); }; + const shouldShowTeamSelector = + isGeneralResearchTheme(activeTheme) && activeTools["subagent_mode"]; + const hasHighlightedAdvancedPreference = + activeTools["thinking"] || + activeTools["web_search"] || + activeTools["subagent_mode"] || + executionStrategy === "code_orchestrated" || + accessMode === "read-only" || + accessMode === "full-access"; + const shouldShowAdvancedToggle = + showSkillSelector || + shouldShowTeamSelector || + Boolean(setExecutionStrategy) || + shouldShowModelControls || + Boolean(setAccessMode); + const leftExtra = shouldShowAdvancedToggle ? ( + <> + setShowAdvancedControls((previous) => !previous)} + > + + + + + 高级设置 + {showAdvancedControls ? ( + + ) : ( + + )} + + + {!showAdvancedControls && currentModelSummary ? ( + + 当前模型 + {trimmedModel} + + ) : null} + + {!showAdvancedControls && shouldShowModelControls && !hasConfiguredModel ? ( + + ) : null} + + {showAdvancedControls ? ( + <> + {showSkillSelector ? ( + + ) : null} + {shouldShowTeamSelector ? ( + onSelectTeam?.(team)} + /> + ) : null} + + {shouldShowModelControls ? ( + + ) : null} + + + ) : null} + + ) : undefined; if (renderWorkflowGeneratingPanel) { return ( @@ -215,48 +340,8 @@ export const InputbarComposerSection: React.FC< queuedTurns={queuedTurns} onPromoteQueuedTurn={onPromoteQueuedTurn} onRemoveQueuedTurn={onRemoveQueuedTurn} - leftExtra={ - <> - {showSkillSelector ? ( - - ) : null} - {isGeneralResearchTheme(activeTheme) ? ( - activeTools["subagent_mode"] ? ( - onSelectTeam?.(team)} - /> - ) : null - ) : null} - - {!isWorkspaceVariant ? ( - - ) : null} - - - } + leftExtra={leftExtra} + showMetaTools={showAdvancedControls} /> ); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx index ee07655b2..8a85f6e20 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx @@ -77,6 +77,7 @@ interface InputbarCoreProps { queuedTurns?: QueuedTurnSnapshot[]; onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise; onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise; + showMetaTools?: boolean; } export const InputbarCore: React.FC = ({ @@ -103,6 +104,7 @@ export const InputbarCore: React.FC = ({ queuedTurns = [], onPromoteQueuedTurn, onRemoveQueuedTurn, + showMetaTools = true, }) => { const [isComposerExpanded, setIsComposerExpanded] = useState(false); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); @@ -421,7 +423,7 @@ export const InputbarCore: React.FC = ({ {leftExtra ? {leftExtra} : null} - {!shouldCollapseFloatingTools ? ( + {!shouldCollapseFloatingTools && showMetaTools ? ( = ({ setModel, activeTheme, onManageProviders, - executionRuntime = null, + executionRuntime: _executionRuntime = null, }) => { - if (isFullscreen || !providerType || !model) { + if (isFullscreen) { return null; } const selectorBackgroundPreload = hasTauriInvokeCapability() @@ -34,9 +34,9 @@ export const InputbarModelExtra: React.FC = ({ return (
(
- - - {props.activeTools?.web_search ? "on" : "off"} - - - - {props.activeTools?.subagent_mode ? "on" : "off"} - + {props.showMetaTools ? ( + <> + + + {props.activeTools?.web_search ? "on" : "off"} + + + + {props.activeTools?.subagent_mode ? "on" : "off"} + + + ) : null} + ) : null} {msg.imageWorkbenchPreview ? ( = ({ const visibleArtifacts = artifacts?.filter( (artifact) => - !isHiddenInternalArtifactPath( + !isHiddenConversationArtifactPath( resolveArtifactProtocolFilePath(artifact), ), ) || []; @@ -865,11 +977,11 @@ const MessageListInner: React.FC = ({ onClick={() => onArtifactClick?.(artifact)} className="w-full flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2 text-left transition-colors hover:border-primary/50 hover:bg-background" > -
+
{artifact.status === "streaming" ? ( - + ) : ( - + )}
@@ -894,8 +1006,8 @@ const MessageListInner: React.FC = ({ ) : null}
-
- +
+
); diff --git a/src/components/agent/chat/components/README.md b/src/components/agent/chat/components/README.md index 51a9103b1..b6c142829 100644 --- a/src/components/agent/chat/components/README.md +++ b/src/components/agent/chat/components/README.md @@ -4,20 +4,45 @@ Agent 聊天界面的 UI 组件集合。 ## 文件索引 -| 文件 | 说明 | -|------|------| -| `ChatNavbar.tsx` | 聊天顶部导航栏 | -| `ChatSidebar.tsx` | 聊天侧边栏(会话列表) | -| `CanvasWorkbenchLayout.tsx` | 画布内工作台布局,宽屏侧栏/窄屏底部工作台均支持产物、全部文件、变更、预览与下载动作 | -| `EmptyState.tsx` | 空状态占位组件 | -| `MarkdownRenderer.tsx` | Markdown 渲染组件 | -| `MessageList.tsx` | 消息列表组件 | -| `StreamingRenderer.tsx` | 流式消息渲染(支持思考内容、工具调用) | -| `TokenUsageDisplay.tsx` | Token 使用量显示 | -| `ToolCallDisplay.tsx` | 工具调用显示(状态、参数、日志、结果) | +| 文件 | 说明 | +| -------------------------------- | -------------------------------------------------------------------------------------- | +| `ChatNavbar.tsx` | 聊天顶部导航栏 | +| `ChatSidebar.tsx` | 聊天侧边栏(会话列表) | +| `CanvasWorkbenchLayout.tsx` | 画布顶部标签壳,统一承载 `Session · Main`、`workspace`、Team 与动态文件标签 | +| `CanvasSessionOverviewPanel.tsx` | 解耦后的会话过程面板,用于从运行时注入 turn、tool、A2UI、queue 等过程视图 | +| `EmptyState.tsx` | 空状态占位组件 | +| `MarkdownRenderer.tsx` | Markdown 渲染组件 | +| `MessageList.tsx` | 消息列表组件 | +| `team-workspace-board/TeamWorkspaceBoardHeader.tsx` | Team 头部壳,统一承载标题、状态 badge、空 shell 展开和返回主助手入口 | +| `team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx` | Team 自由画布里的 lane 卡片壳,统一承载成员进展、摘要、badge 与 resize handle | +| `team-workspace-board/TeamWorkspaceCanvasStage.tsx` | Team 自由画布 stage 壳,统一承载视口、背景网格、shortcuts、lane 容器与空态 | +| `team-workspace-board/TeamWorkspaceEmptyShellState.tsx` | Team 空 shell 折叠态横条,保持壳层紧凑与展开入口统一 | +| `team-workspace-board/TeamWorkspaceFormationPanels.tsx` | Team 运行时分工 / 计划分工展示壳,避免 Board 内重复 JSX | +| `team-workspace-board/SelectedSessionInlineDetail.tsx` | Team 当前查看成员的详情区与协作操作壳 | +| `team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx` | Team 顶部 toolbar / 协作动态壳,收口 compact 与非 compact 的重复展示逻辑 | +| `StreamingRenderer.tsx` | 流式消息渲染(支持思考内容、工具调用) | +| `TokenUsageDisplay.tsx` | Token 使用量显示 | +| `ToolCallDisplay.tsx` | 工具调用显示(状态、参数、日志、结果) | ## 核心组件 +### CanvasWorkbenchLayout + +- 只负责单画布壳、头部标签、文件标签与顶部操作,不直接依赖具体 slash / skill 业务 +- 顶部收敛为紧凑标签栏 + 文件操作区,去掉大块摘要头,避免与正文和对话信息重复 +- 当存在 `sessionView` 且同时有默认主稿时,默认焦点仍应落在 `Session · Main`,但面板内容优先展示当前主稿/产物预览,避免与对话里的过程区重复 +- `workspaceView` 可以由运行时显式注入顶部标签文案和面板文案,工作区文件区是画布里真实文件的唯一事实源 +- `workspace` 文件树默认隐藏 `.lime`、`exports`、`output`、`.DS_Store` 和 `output_image.*` 这类内部运行或导出产物入口,避免与真实编辑入口混淆 +- `panelCopy` 允许运行时覆盖 workspace / team 面板里的引导文案、空态文案与目录区标题,避免布局壳继续承载场景描述 +- `teamView` 与可选的 `tabLabel/tabBadge` 继续由运行时显式提供,布局只消费定义,不再内建 Team 协作语义 +- 通过 `sessionView`、`teamView` 等插槽注入不同运行时面板,保持未来场景扩展时的边界稳定 + +### CanvasSessionOverviewPanel + +- 统一展示当前 turn 状态、最近执行轨迹、待处理交互与排队消息 +- 顶部改为“执行总览”摘要卡,优先展示当前状态、聚焦事件和 turn 信息,而不是技术性提示文案 +- 让 `Session · Main` 成为真实过程页,而不是拿主稿预览兜底 + ### ToolCallDisplay 参考 aster UI 设计,提供完整的工具调用可视化: diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx index 14a69faa7..1937df940 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx @@ -676,8 +676,9 @@ describe("TeamWorkspaceBoard", () => { expect(currentLane?.textContent).toContain( "推理:先检查 team runtime 的控制面状态,再决定是否等待。", ); + expect(siblingLane?.textContent).toContain("页面截图"); expect(siblingLane?.textContent).toContain( - "工具 browser_snapshot:页面已刷新为最新状态并生成差异截图。", + "页面已刷新为最新状态并生成差异截图。", ); const returnButton = Array.from(container.querySelectorAll("button")).find( @@ -703,8 +704,9 @@ describe("TeamWorkspaceBoard", () => { ).toBeTruthy(); expect(container.textContent).toContain("检索结果"); expect(container.textContent).toContain("已汇总 5 条 roadmap 差异。"); + expect(container.textContent).toContain("页面截图"); expect(container.textContent).toContain( - "工具 browser_snapshot:页面已刷新为最新状态并生成差异截图。", + "页面已刷新为最新状态并生成差异截图。", ); expect(container.textContent).toContain("等待父线程确认是否继续扩展范围。"); @@ -985,7 +987,7 @@ describe("TeamWorkspaceBoard", () => { "child-live-stream-1": [ { id: "tool:child-live-stream-1:tool-1", - title: "工具 browser_snapshot", + title: "工具 页面截图", detail: "页面结构差异已提取完成。", statusLabel: "完成", badgeClassName: @@ -1000,9 +1002,9 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("实时片段代理"); expect(container.textContent).toContain( - "工具 browser_snapshot:页面结构差异已提取完成。", + "工具 页面截图:页面结构差异已提取完成。", ); - expect(container.textContent).toContain("工具 browser_snapshot"); + expect(container.textContent).toContain("工具 页面截图"); expect(container.textContent).toContain("页面结构差异已提取完成。"); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx index 7025331e0..5d52261d7 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx @@ -8,44 +8,23 @@ import { type WheelEvent as ReactWheelEvent, } from "react"; import { - Activity, - ArrowUpLeft, Bot, - ChevronDown, - ChevronUp, - Clock3, - Loader2, - PanelTop, - Workflow, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; import { getAgentRuntimeSession } from "@/lib/api/agentRuntime"; -import type { AgentThreadItem } from "@/lib/api/agentProtocol"; import type { - AsterSessionDetail, AsterSubagentSkillInfo, AsterSubagentParentContext, AsterSubagentSessionInfo, } from "@/lib/api/agentRuntime"; import { formatRelativeTime } from "@/lib/api/project"; import { cn } from "@/lib/utils"; -import { getTeamPresetOption } from "../utils/teamPresets"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; import { applyLiveRuntimeState, - buildTeamWorkspaceActivityEntryFromThreadItem, - buildTeamWorkspaceSessionFingerprint, isTeamWorkspaceActiveStatus, - isTeamWorkspaceTerminalStatus, - mergeSessionActivityEntries, - normalizeTeamWorkspaceRuntimeStatus, - resolveRuntimeFormationStatusMeta, - resolveRuntimeMemberStatusMeta, - resolveTeamWorkspaceRuntimeStatusLabel, type TeamWorkspaceActivityEntry, type TeamWorkspaceControlSummary, - type TeamWorkspaceRuntimeMember, type TeamWorkspaceLiveRuntimeState, type TeamWorkspaceRuntimeFormationState, type TeamWorkspaceWaitSummary, @@ -65,16 +44,44 @@ import { type TeamWorkspaceCanvasLayoutState, } from "../utils/teamWorkspaceCanvas"; import { - buildTeamWorkspaceSkillDisplayName, resolveTeamWorkspaceDisplayRuntimeStatusLabel, - resolveTeamWorkspaceDisplaySessionTypeLabel, - resolveTeamWorkspaceRoleHintLabel, - resolveTeamWorkspaceStableProcessingLabel, TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL, - TEAM_WORKSPACE_PLAN_LABEL, - TEAM_WORKSPACE_REALTIME_BADGE_LABEL, - TEAM_WORKSPACE_SURFACE_TITLE, } from "../utils/teamWorkspaceCopy"; +import { + buildPreviewableRailSessionsSyncKey, + buildSelectedSessionActivityState, + collectStaleSessionActivityTargets, + extractSessionActivitySnapshot, + type SessionActivityPreviewState, +} from "../team-workspace-runtime/activityPreviewSelectors"; +import { buildTeamWorkspaceBoardChromeDisplayState } from "../team-workspace-runtime/boardChromeSelectors"; +import { + buildTeamWorkspaceCanvasLanes, + type TeamWorkspaceCanvasLane, + type TeamWorkspaceCanvasLaneKind, +} from "../team-workspace-runtime/canvasLaneSelectors"; +import { + buildRuntimeFormationDisplayState, + buildSelectedTeamPlanDisplayState, +} from "../team-workspace-runtime/formationDisplaySelectors"; +import { buildSelectedSessionDetailDisplayState } from "../team-workspace-runtime/selectedSessionDetailSelectors"; +import { + buildTeamWorkspaceSessionControlState, + isWaitableTeamSession, +} from "../team-workspace-runtime/sessionStateSelectors"; +import { + buildVisibleTeamOperationState, + type TeamOperationDisplayEntry, +} from "../team-workspace-runtime/teamOperationSelectors"; +import { TeamWorkspaceBoardHeader } from "./team-workspace-board/TeamWorkspaceBoardHeader"; +import { TeamWorkspaceEmptyShellState } from "./team-workspace-board/TeamWorkspaceEmptyShellState"; +import { + TeamWorkspaceRuntimeFormationPanel, + TeamWorkspaceSelectedPlanPanel, +} from "./team-workspace-board/TeamWorkspaceFormationPanels"; +import { TeamWorkspaceCanvasStage } from "./team-workspace-board/TeamWorkspaceCanvasStage"; +import { SelectedSessionInlineDetail } from "./team-workspace-board/SelectedSessionInlineDetail"; +import { TeamWorkspaceTeamOverviewChrome } from "./team-workspace-board/TeamWorkspaceTeamOverviewChrome"; type RuntimeStatus = AsterSubagentSessionInfo["runtime_status"]; @@ -154,30 +161,8 @@ interface TeamSessionCard { isCurrent?: boolean; } -interface SessionActivityPreviewState { - preview: string | null; - entries: SessionActivityEntry[]; - status: "loading" | "ready" | "error"; - errorMessage?: string; - fingerprint?: string; - refreshVersion?: number; - syncedAt?: number; -} - -interface TeamOperationDisplayEntry { - id: string; - title: string; - detail: string; - badgeClassName: string; - updatedAt: number; - targetSessionId?: string; -} - -type SessionActivityEntry = TeamWorkspaceActivityEntry; const ACTIVITY_PREVIEW_POLL_INTERVAL_MS = 1500; -const ACTIVITY_PREVIEW_MAX_LENGTH = 360; const ACTIVITY_TIMELINE_ENTRY_LIMIT = 4; -const ACTIVITY_TIMELINE_DETAIL_MAX_LENGTH = 220; const DEFAULT_WAIT_SELECTED_SUBAGENT_TIMEOUT_MS = 30_000; const STATUS_META: Record< @@ -237,10 +222,6 @@ function resolveStatusMeta(status?: RuntimeStatus) { return STATUS_META[status ?? "idle"]; } -function resolveSessionTypeLabel(value?: string) { - return resolveTeamWorkspaceDisplaySessionTypeLabel(value); -} - function formatUpdatedAt(updatedAt?: number) { if (!updatedAt) { return "刚刚"; @@ -248,17 +229,6 @@ function formatUpdatedAt(updatedAt?: number) { return formatRelativeTime(updatedAt * 1000); } -function formatOperationUpdatedAt(updatedAt?: number) { - if (!updatedAt) { - return "刚刚"; - } - return formatRelativeTime(updatedAt); -} - -function buildSkillDisplayName(skill: AsterSubagentSkillInfo): string { - return buildTeamWorkspaceSkillDisplayName(skill); -} - function canStartCanvasPanGesture( target: EventTarget | null, currentTarget: EventTarget | null, @@ -282,191 +252,6 @@ function canStartCanvasPanGesture( ); } -function normalizeActivityPreviewText( - value?: string | null, - maxLength = ACTIVITY_PREVIEW_MAX_LENGTH, -): string | null { - const normalized = value - ?.replace(/\r\n/g, "\n") - .split("\0") - .join("") - .replace(/\n{3,}/g, "\n\n") - .trim(); - - if (!normalized) { - return null; - } - - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, maxLength).trimEnd()}...`; -} - -function buildActivityPreviewLine( - label: string, - value?: string | null, -): string | null { - const normalized = normalizeActivityPreviewText(value); - if (!normalized) { - return null; - } - return `${label}:${normalized}`; -} - -function resolveActivityEntryStatusMeta( - item: AgentThreadItem | { type: "message_fallback" }, - status?: AgentThreadItem["status"], -) { - if (item.type === "error") { - return { - label: "错误", - badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700", - }; - } - - if (item.type === "warning") { - return { - label: "警告", - badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700", - }; - } - - switch (status) { - case "in_progress": - return { - label: "进行中", - badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", - }; - case "failed": - return { - label: "失败", - badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700", - }; - case "completed": - return { - label: "完成", - badgeClassName: - "border border-emerald-200 bg-emerald-50 text-emerald-700", - }; - default: - return { - label: "消息", - badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600", - }; - } -} - -function buildActivityPreviewFromEntry(entry?: SessionActivityEntry | null) { - if (!entry) { - return null; - } - - return buildActivityPreviewLine(entry.title, entry.detail); -} - -function extractMessageActivityEntries( - detail: AsterSessionDetail, -): SessionActivityEntry[] { - const reversedMessages = [...detail.messages].sort( - (left, right) => right.timestamp - left.timestamp, - ); - - for (const message of reversedMessages) { - if (message.role !== "assistant") { - continue; - } - - for (const content of message.content) { - const title = - content.type === "tool_response" - ? content.error - ? "错误" - : content.output - ? "输出" - : "回复" - : "回复"; - const previewSource = - content.type === "tool_response" - ? content.error || content.output - : content.type === "text" || content.type === "thinking" - ? content.text - : undefined; - const detailText = normalizeActivityPreviewText( - previewSource, - ACTIVITY_TIMELINE_DETAIL_MAX_LENGTH, - ); - - if (detailText) { - const statusMeta = resolveActivityEntryStatusMeta( - { type: "message_fallback" }, - undefined, - ); - return [ - { - id: `message-${message.id ?? message.timestamp}`, - title, - detail: detailText, - statusLabel: statusMeta.label, - badgeClassName: statusMeta.badgeClassName, - }, - ]; - } - } - } - - return []; -} - -function extractSessionActivitySnapshot(detail: AsterSessionDetail): { - preview: string | null; - entries: SessionActivityEntry[]; -} { - const orderedItems = [...(detail.items ?? [])].sort( - (left, right) => right.sequence - left.sequence, - ); - const entries: SessionActivityEntry[] = []; - - for (const item of orderedItems) { - const entry = buildTeamWorkspaceActivityEntryFromThreadItem(item); - if (entry) { - entries.push(entry); - } - if (entries.length >= ACTIVITY_TIMELINE_ENTRY_LIMIT) { - break; - } - } - - if (entries.length > 0) { - return { - preview: buildActivityPreviewFromEntry(entries[0]), - entries, - }; - } - - const messageEntries = extractMessageActivityEntries(detail); - return { - preview: buildActivityPreviewFromEntry(messageEntries[0]), - entries: messageEntries, - }; -} - -function shouldPollSessionActivity(session?: TeamSessionCard | null) { - const runtimeStatus = session?.runtimeStatus; - const latestTurnStatus = session?.latestTurnStatus; - return ( - runtimeStatus === "running" || - runtimeStatus === "queued" || - latestTurnStatus === "running" || - latestTurnStatus === "queued" - ); -} - -function buildSessionActivityFingerprint(session?: TeamSessionCard | null) { - return buildTeamWorkspaceSessionFingerprint(session); -} - function isEditableKeyboardTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false; @@ -482,179 +267,6 @@ function isEditableKeyboardTarget(target: EventTarget | null): boolean { ); } -function buildRuntimeDetailSummary( - session?: TeamSessionCard | null, -): string | null { - if (!session) { - return null; - } - - const parts: string[] = []; - const waitingCount = session.teamQueuedCount ?? session.queuedTurnCount ?? 0; - if (waitingCount > 0) { - parts.push(`等待中 ${waitingCount}`); - } - if (session.latestTurnStatus) { - parts.push(`最近进展 ${resolveStatusMeta(session.latestTurnStatus).label}`); - } - if ( - session.teamActiveCount !== undefined && - session.teamParallelBudget !== undefined - ) { - parts.push( - `处理中 ${session.teamActiveCount}/${session.teamParallelBudget}`, - ); - } - if ( - session.providerParallelBudget === 1 && - session.providerConcurrencyGroup - ) { - parts.push(resolveTeamWorkspaceStableProcessingLabel()); - } - - return parts.length > 0 ? parts.join(" · ") : null; -} - -function isWaitableTeamSession(session?: TeamSessionCard | null) { - return Boolean( - session && - session.sessionType !== "user" && - !isTeamWorkspaceTerminalStatus(session.runtimeStatus), - ); -} - -function isCompletedTeamSession(session?: TeamSessionCard | null) { - return ( - session?.runtimeStatus === "completed" || - session?.runtimeStatus === "failed" || - session?.runtimeStatus === "aborted" - ); -} - -function buildTeamWaitSummaryDisplay( - summary: TeamWorkspaceWaitSummary, - sessionNameById: Map, -): { - text: string; - badgeClassName: string; -} { - if (summary.timedOut) { - return { - text: `刚才等待结果时超时了,还有 ${summary.awaitedSessionIds.length} 位协作成员仍在处理中。`, - badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700", - }; - } - - const resolvedName = summary.resolvedSessionId - ? (sessionNameById.get(summary.resolvedSessionId) ?? - summary.resolvedSessionId) - : "成员"; - const normalizedStatus = summary.resolvedStatus - ? normalizeTeamWorkspaceRuntimeStatus(summary.resolvedStatus) - : undefined; - const statusMeta = resolveStatusMeta(normalizedStatus); - - return { - text: `刚才等到 ${resolvedName} 返回了新结果,当前状态为${resolveTeamWorkspaceRuntimeStatusLabel(summary.resolvedStatus)}。`, - badgeClassName: statusMeta.badgeClassName, - }; -} - -function buildTeamControlSummaryDisplay( - summary: TeamWorkspaceControlSummary, - sessionNameById: Map, -): { - text: string; - badgeClassName: string; -} { - const affectedCount = summary.affectedSessionIds.length; - const firstAffectedId = summary.affectedSessionIds[0]; - const firstAffectedName = firstAffectedId - ? (sessionNameById.get(firstAffectedId) ?? firstAffectedId) - : "成员"; - - switch (summary.action) { - case "resume": - return { - text: - affectedCount > 1 - ? `刚才已继续 ${affectedCount} 位协作成员的处理。` - : `刚才已继续 ${firstAffectedName} 的处理。`, - badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", - }; - case "close_completed": - return { - text: `刚才已收起 ${affectedCount} 位已完成成员。`, - badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700", - }; - case "close": - default: - return { - text: - affectedCount > 1 - ? `刚才已暂停 ${affectedCount} 位协作成员的处理。` - : `刚才已暂停 ${firstAffectedName} 的处理。`, - badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700", - }; - } -} - -function buildTeamOperationDisplayEntries(params: { - sessionNameById: Map; - teamWaitSummary?: TeamWorkspaceWaitSummary | null; - teamControlSummary?: TeamWorkspaceControlSummary | null; -}): TeamOperationDisplayEntry[] { - const entries: TeamOperationDisplayEntry[] = []; - - if (params.teamWaitSummary) { - const display = buildTeamWaitSummaryDisplay( - params.teamWaitSummary, - params.sessionNameById, - ); - entries.push({ - id: `wait-${params.teamWaitSummary.updatedAt}`, - title: params.teamWaitSummary.timedOut ? "等待超时" : "收到结果", - detail: display.text, - badgeClassName: display.badgeClassName, - updatedAt: params.teamWaitSummary.updatedAt, - targetSessionId: - params.teamWaitSummary.resolvedSessionId ?? - params.teamWaitSummary.awaitedSessionIds[0], - }); - } - - if (params.teamControlSummary) { - const display = buildTeamControlSummaryDisplay( - params.teamControlSummary, - params.sessionNameById, - ); - const title = (() => { - switch (params.teamControlSummary.action) { - case "resume": - return "继续处理"; - case "close_completed": - return "收起完成项"; - case "close": - default: - return "暂停处理"; - } - })(); - - entries.push({ - id: `control-${params.teamControlSummary.action}-${params.teamControlSummary.updatedAt}`, - title, - detail: display.text, - badgeClassName: display.badgeClassName, - updatedAt: params.teamControlSummary.updatedAt, - targetSessionId: - params.teamControlSummary.affectedSessionIds[0] ?? - params.teamControlSummary.requestedSessionIds[0], - }); - } - - return entries.sort((left, right) => right.updatedAt - left.updatedAt); -} - function buildCurrentChildSession( currentSessionId?: string | null, currentSessionName?: string | null, @@ -862,48 +474,6 @@ function orderSessionsByRuntimeRoles( .map((entry) => entry.session); } -function buildBoardHeadline(params: { - hasRealTeamGraph: boolean; - isChildSession: boolean; - subagentParentContext?: AsterSubagentParentContext | null; - totalTeamSessions: number; -}) { - const { - hasRealTeamGraph, - isChildSession, - subagentParentContext, - totalTeamSessions, - } = params; - - if (isChildSession) { - return subagentParentContext?.parent_session_name?.trim() || "主助手协作区"; - } - if (!hasRealTeamGraph) { - return "需要时会自动加入协作成员"; - } - return totalTeamSessions > 0 - ? `${totalTeamSessions} 位成员协作中` - : "创作协作"; -} - -function buildBoardHint(params: { - hasRealTeamGraph: boolean; - isChildSession: boolean; - siblingCount: number; -}) { - const { hasRealTeamGraph, isChildSession, siblingCount } = params; - - if (isChildSession) { - return siblingCount > 0 - ? `当前正与 ${siblingCount} 位协作成员并行推进` - : "当前由你和这位协作成员一起推进"; - } - if (!hasRealTeamGraph) { - return "系统会在需要时自动邀请协作成员加入,不需要你理解内部分工方式。"; - } - return "这里只展示谁在帮你处理什么、处理到哪一步,以及接下来会给你什么结果。"; -} - function buildFallbackSummary(params: { hasRealTeamGraph: boolean; isChildSession: boolean; @@ -923,99 +493,6 @@ function buildFallbackSummary(params: { return "选中一位协作成员后,这里会展示它正在帮你做什么,以及目前进展到哪一步。"; } -function buildRuntimeFormationHint( - teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null, -) { - switch (teamDispatchPreviewState?.status) { - case "forming": - return "系统正在准备当前任务的协作分工,成员接入后会自动开始处理。"; - case "formed": - return "当前任务的协作分工已经准备好,成员加入后会继续接手处理。"; - case "failed": - return "当前任务的协作准备失败,但你仍然可以继续在当前对话里推进。"; - default: - return "需要时这里会自动展开成协作面板。"; - } -} - -function buildRuntimeFormationEmptyDetail( - teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null, -) { - switch (teamDispatchPreviewState?.status) { - case "forming": - return "系统正在根据当前任务准备协作分工。完成后,这里会先展示当前成员卡片,再接入真实处理进展。"; - case "formed": - return "当前协作方案已经准备好。画布会先展示当前分工,等成员真正开始处理后,再自动切换为实时进展。"; - case "failed": - return ( - teamDispatchPreviewState.errorMessage?.trim() || - "当前协作准备失败,暂时无法展示当前成员。" - ); - default: - return "当前还没有协作成员加入。系统开始分工后,详情区会切换为成员摘要视图。"; - } -} - -function buildSessionLaneEmptyState(params: { - session?: TeamSessionCard | null; - previewState?: SessionActivityPreviewState | null; -}) { - const { session, previewState } = params; - - if (previewState?.status === "error") { - return previewState.errorMessage?.trim() || "同步最新内容失败"; - } - - if (previewState?.status === "loading") { - return "正在同步这位协作成员的最新内容..."; - } - - if (session?.runtimeStatus === "queued") { - return "这位协作成员已经收到任务,马上开始处理。"; - } - - if (session?.runtimeStatus === "running") { - return "这位协作成员正在处理,最新进展会持续刷新到这里。"; - } - - if (session?.runtimeStatus === "completed") { - return "这部分已经完成,结果会继续汇入当前内容。"; - } - - if ( - session?.runtimeStatus === "failed" || - session?.runtimeStatus === "aborted" - ) { - return "这一步没有顺利完成,你可以在下方查看细节并决定是否继续。"; - } - - return "这位协作成员暂时还没有产出可展示的内容。"; -} - -type TeamWorkspaceCanvasLaneKind = "session" | "runtime" | "planned"; - -interface TeamWorkspaceCanvasLane { - id: string; - persistKey: string; - fallbackPersistKeys: string[]; - kind: TeamWorkspaceCanvasLaneKind; - title: string; - summary: string; - badgeLabel: string; - badgeClassName: string; - dotClassName: string; - roleLabel?: string; - profileLabel?: string; - presetLabel?: string; - modelLabel?: string; - statusHint?: string | null; - updatedAtLabel?: string | null; - skillLabels: string[]; - session?: TeamSessionCard; - previewText?: string | null; - previewEntries?: SessionActivityEntry[]; -} - interface TeamWorkspaceCanvasBounds { minX: number; minY: number; @@ -1130,217 +607,6 @@ function buildCanvasStageHint(params: { return "协作成员加入后,这里会展开成可拖拽、可缩放的进展画布。"; } -function buildCanvasLaneTitleSummary( - member: Pick, -) { - const memberMeta = resolveRuntimeMemberStatusMeta(member.status); - const statusHint = - member.status === "spawning" - ? "正在接入协作成员" - : member.status === "running" - ? "这位协作成员正在处理" - : member.status === "waiting" - ? "等待继续补充说明" - : member.status === "completed" - ? "这一步已经完成" - : member.status === "failed" - ? "这一步需要重试" - : member.sessionId - ? "已连接到真实成员" - : "等待协作成员加入"; - - return { - badgeLabel: memberMeta.label, - badgeClassName: memberMeta.badgeClassName, - dotClassName: - member.status === "failed" - ? "bg-rose-500" - : member.status === "completed" - ? "bg-emerald-500" - : member.status === "waiting" - ? "bg-amber-400" - : "bg-sky-500", - summary: member.summary, - statusHint, - }; -} - -function buildPlannedRoleLaneSummary(role: TeamRoleDefinition) { - return { - badgeLabel: "待开始", - badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600", - dotClassName: "bg-slate-300", - summary: role.summary, - statusHint: "等待系统邀请协作成员加入", - }; -} - -function resolveLaneMatchingRuntimeMemberId( - session: TeamSessionCard, - runtimeMembers: TeamWorkspaceRuntimeMember[], -): string | null { - if (runtimeMembers.length === 0) { - return null; - } - - const explicitRoleId = session.blueprintRoleId?.trim(); - if ( - explicitRoleId && - runtimeMembers.some((member) => member.id === explicitRoleId) - ) { - return explicitRoleId; - } - - const normalizedRoleLabel = normalizeComparableText( - session.blueprintRoleLabel || session.name, - ); - const normalizedRoleKey = normalizeComparableText( - session.roleKey || session.roleHint, - ); - const normalizedProfileId = normalizeComparableText(session.profileId); - - const candidates = runtimeMembers - .map((member) => { - let score = 0; - if ( - normalizedRoleLabel && - normalizeComparableText(member.label) === normalizedRoleLabel - ) { - score += 8; - } - if ( - normalizedRoleKey && - normalizeComparableText(member.roleKey) === normalizedRoleKey - ) { - score += 5; - } - if ( - normalizedProfileId && - normalizeComparableText(member.profileId) === normalizedProfileId - ) { - score += 4; - } - return { - memberId: member.id, - score, - }; - }) - .filter((candidate) => candidate.score > 0) - .sort((left, right) => right.score - left.score); - - if (candidates.length === 0) { - return null; - } - if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) { - return null; - } - return candidates[0]?.memberId ?? null; -} - -function resolveLaneMatchingPlannedRoleId( - session: TeamSessionCard, - plannedRoles: TeamRoleDefinition[], -): string | null { - if (plannedRoles.length === 0) { - return null; - } - - const normalizedRoleLabel = normalizeComparableText( - session.blueprintRoleLabel || session.name, - ); - const normalizedRoleKey = normalizeComparableText( - session.roleKey || session.roleHint, - ); - const normalizedProfileId = normalizeComparableText(session.profileId); - - const candidates = plannedRoles - .map((role) => { - let score = 0; - if ( - normalizedRoleLabel && - normalizeComparableText(role.label) === normalizedRoleLabel - ) { - score += 8; - } - if ( - normalizedRoleKey && - normalizeComparableText(role.roleKey) === normalizedRoleKey - ) { - score += 5; - } - if ( - normalizedProfileId && - normalizeComparableText(role.profileId) === normalizedProfileId - ) { - score += 4; - } - return { - roleId: role.id, - score, - }; - }) - .filter((candidate) => candidate.score > 0) - .sort((left, right) => right.score - left.score); - - if (candidates.length === 0) { - return null; - } - if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) { - return null; - } - return candidates[0]?.roleId ?? null; -} - -function resolveRuntimeMemberMatchingPlannedRoleId( - member: TeamWorkspaceRuntimeMember, - plannedRoles: TeamRoleDefinition[], -): string | null { - if (plannedRoles.length === 0) { - return null; - } - - const normalizedRoleLabel = normalizeComparableText(member.label); - const normalizedRoleKey = normalizeComparableText(member.roleKey); - const normalizedProfileId = normalizeComparableText(member.profileId); - - const candidates = plannedRoles - .map((role) => { - let score = 0; - if ( - normalizedRoleLabel && - normalizeComparableText(role.label) === normalizedRoleLabel - ) { - score += 8; - } - if ( - normalizedRoleKey && - normalizeComparableText(role.roleKey) === normalizedRoleKey - ) { - score += 5; - } - if ( - normalizedProfileId && - normalizeComparableText(role.profileId) === normalizedProfileId - ) { - score += 4; - } - return { - roleId: role.id, - score, - }; - }) - .filter((candidate) => candidate.score > 0) - .sort((left, right) => right.score - left.score); - - if (candidates.length === 0) { - return null; - } - if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) { - return null; - } - return candidates[0]?.roleId ?? null; -} - function resolveCanvasLaneBounds( layouts: TeamWorkspaceCanvasItemLayout[], ): TeamWorkspaceCanvasBounds { @@ -1650,217 +916,22 @@ export function TeamWorkspaceBoard({ const normalizedSelectedTeamRoles = (selectedTeamRoles ?? []).filter((role) => role.label.trim(), ); - const runtimeFormationMeta = dispatchPreviewState - ? resolveRuntimeFormationStatusMeta(dispatchPreviewState.status) - : null; - const runtimeFormationLabel = - dispatchPreviewState?.label?.trim() || - dispatchPreviewState?.blueprint?.label?.trim() || - normalizedSelectedTeamLabel; - const runtimeFormationSummary = - dispatchPreviewState?.summary?.trim() || - dispatchPreviewState?.blueprint?.summary?.trim() || - normalizedSelectedTeamSummary; const runtimeMembers = useMemo( () => dispatchPreviewState?.members ?? [], [dispatchPreviewState?.members], ); - const runtimeBlueprintRoles = useMemo( - () => dispatchPreviewState?.blueprint?.roles ?? [], - [dispatchPreviewState?.blueprint?.roles], - ); - const hasRuntimeFormation = Boolean(dispatchPreviewState); - const hasSelectedTeamPlan = - Boolean(normalizedSelectedTeamLabel) || - Boolean(normalizedSelectedTeamSummary) || - normalizedSelectedTeamRoles.length > 0; - - const renderSelectedTeamPlanSummary = () => { - if (!hasSelectedTeamPlan) { - return null; - } - - return ( -
- {normalizedSelectedTeamLabel ? ( - - {TEAM_WORKSPACE_PLAN_LABEL} · {normalizedSelectedTeamLabel} - - ) : null} - {normalizedSelectedTeamRoles.length > 0 ? ( - - {normalizedSelectedTeamRoles.length} 个计划分工 - - ) : null} -
- ); - }; - - const renderSelectedTeamPlanPanel = () => { - if (!hasSelectedTeamPlan) { - return null; - } - - return ( -
-
- - 计划中的协作分工 - {normalizedSelectedTeamLabel ? ( - - {normalizedSelectedTeamLabel} - - ) : null} -
- {normalizedSelectedTeamSummary ? ( -

- {normalizedSelectedTeamSummary} -

- ) : null} - {normalizedSelectedTeamRoles.length > 0 ? ( -
- {normalizedSelectedTeamRoles.map((role) => ( -
-
- - {role.label} - -
-

- {role.summary} -

-
- ))} -
- ) : null} -
- ); - }; - - const renderRuntimeFormationSummary = () => { - if (!hasRuntimeFormation) { - return null; - } - - return ( -
- {runtimeFormationLabel ? ( - - {TEAM_WORKSPACE_PLAN_LABEL} · {runtimeFormationLabel} - - ) : null} - {runtimeFormationMeta ? ( - - {runtimeFormationMeta.label} - - ) : null} - {runtimeMembers.length > 0 ? ( - - {runtimeMembers.length} 位当前成员 - - ) : null} - {dispatchPreviewState?.blueprint?.label ? ( - - 参考方案 · {dispatchPreviewState.blueprint.label} - - ) : null} -
- ); - }; - - const renderRuntimeFormationPanel = () => { - if (!dispatchPreviewState || !runtimeFormationMeta) { - return null; - } - - return ( -
-
- - 当前协作准备 - - {runtimeFormationMeta.label} - - {runtimeFormationLabel ? ( - - {runtimeFormationLabel} - - ) : null} -
-
- {runtimeFormationMeta.title} -
-

- {dispatchPreviewState.status === "failed" - ? dispatchPreviewState.errorMessage?.trim() || - "当前协作准备失败,暂时无法展示更多内容。" - : runtimeFormationSummary || - "这里会先展示当前协作方案,成员加入后再切换成实时进展。"} -

- {dispatchPreviewState.blueprint?.label ? ( -
- 参考方案:{dispatchPreviewState.blueprint.label} -
- ) : null} -
- ); - }; - - const renderRuntimeMemberPanel = () => { - if (runtimeMembers.length === 0) { - return null; - } - - return ( -
- {runtimeMembers.map((member) => { - const memberMeta = resolveRuntimeMemberStatusMeta(member.status); - return ( -
-
- - {member.label} - - - {memberMeta.label} - -
-

- {member.summary} -

-
- ); - })} -
- ); - }; + const selectedTeamPlanDisplay = buildSelectedTeamPlanDisplayState({ + selectedTeamLabel: normalizedSelectedTeamLabel, + selectedTeamSummary: normalizedSelectedTeamSummary, + selectedTeamRoles: normalizedSelectedTeamRoles, + }); + const runtimeFormationDisplay = buildRuntimeFormationDisplayState({ + teamDispatchPreviewState: dispatchPreviewState, + fallbackLabel: normalizedSelectedTeamLabel, + fallbackSummary: normalizedSelectedTeamSummary, + }); + const hasRuntimeFormation = runtimeFormationDisplay.hasRuntimeFormation; + const hasSelectedTeamPlan = selectedTeamPlanDisplay.hasSelectedTeamPlan; const memberCanvasSessions = useMemo( () => @@ -1959,50 +1030,47 @@ export function TeamWorkspaceBoard({ null, [baseRailSessions, selectedSessionId], ); - const selectedSessionActivityPreview = selectedSession - ? (sessionActivityPreviewById[selectedSession.id] ?? null) - : null; - const selectedSessionActivityEntries = selectedSession - ? mergeSessionActivityEntries( - liveActivityBySessionId[selectedSession.id], - selectedSessionActivityPreview?.entries, - ACTIVITY_TIMELINE_ENTRY_LIMIT, - ) - : []; - const selectedSessionActivityPreviewText = - buildActivityPreviewFromEntry(selectedSessionActivityEntries[0]) ?? - selectedSessionActivityPreview?.preview ?? - null; - const selectedSessionSupportsActivityPreview = Boolean( - selectedSession && selectedSession.sessionType !== "user", + const selectedSessionActivityState = useMemo( + () => + buildSelectedSessionActivityState({ + selectedSession, + selectedBaseSession, + liveActivityBySessionId, + previewBySessionId: sessionActivityPreviewById, + activityRefreshVersionBySessionId, + activityTimelineEntryLimit: ACTIVITY_TIMELINE_ENTRY_LIMIT, + }), + [ + activityRefreshVersionBySessionId, + liveActivityBySessionId, + selectedBaseSession, + selectedSession, + sessionActivityPreviewById, + ], ); - const selectedSessionActivityId = selectedSessionSupportsActivityPreview - ? (selectedSession?.id ?? null) - : null; + const selectedSessionActivityPreview = selectedSessionActivityState.previewState; + const selectedSessionActivityEntries = selectedSessionActivityState.entries; + const selectedSessionActivityPreviewText = + selectedSessionActivityState.previewText; + const selectedSessionSupportsActivityPreview = + selectedSessionActivityState.supportsPreview; + const selectedSessionActivityId = selectedSessionActivityState.activityId; const selectedSessionActivityFingerprint = - selectedSessionSupportsActivityPreview - ? buildSessionActivityFingerprint(selectedBaseSession) - : null; - const selectedSessionActivityRefreshVersion = selectedSessionActivityId - ? (activityRefreshVersionBySessionId[selectedSessionActivityId] ?? 0) - : 0; + selectedSessionActivityState.fingerprint; + const selectedSessionActivityRefreshVersion = + selectedSessionActivityState.refreshVersion; const selectedSessionActivityShouldPoll = - selectedSessionSupportsActivityPreview && - shouldPollSessionActivity(selectedSession); + selectedSessionActivityState.shouldPoll; const basePreviewableRailSessions = useMemo( () => baseRailSessions.filter((session) => session.sessionType !== "user"), [baseRailSessions], ); const previewableRailSessionsSyncKey = useMemo( () => - basePreviewableRailSessions - .map((session) => { - const fingerprint = buildSessionActivityFingerprint(session); - const refreshVersion = - activityRefreshVersionBySessionId[session.id] ?? 0; - return `${session.id}:${fingerprint}:${refreshVersion}`; - }) - .join("|"), + buildPreviewableRailSessionsSyncKey({ + sessions: basePreviewableRailSessions, + activityRefreshVersionBySessionId, + }), [activityRefreshVersionBySessionId, basePreviewableRailSessions], ); @@ -2055,7 +1123,10 @@ export function TeamWorkspaceBoard({ try { const detail = await getAgentRuntimeSession(sessionId); - const activitySnapshot = extractSessionActivitySnapshot(detail); + const activitySnapshot = extractSessionActivitySnapshot( + detail, + ACTIVITY_TIMELINE_ENTRY_LIMIT, + ); const syncedAt = Date.now(); setSessionActivityPreviewById((previous) => ({ ...previous, @@ -2147,17 +1218,13 @@ export function TeamWorkspaceBoard({ ]); useEffect(() => { - const staleSessions = basePreviewableRailSessions.filter((session) => { - const fingerprint = buildSessionActivityFingerprint(session); - const cachedPreview = sessionActivityPreviewByIdRef.current[session.id]; - const refreshVersion = activityRefreshVersionBySessionId[session.id] ?? 0; - return ( - cachedPreview?.fingerprint !== fingerprint || - (cachedPreview?.refreshVersion ?? 0) < refreshVersion - ); + const staleTargets = collectStaleSessionActivityTargets({ + sessions: basePreviewableRailSessions, + previewBySessionId: sessionActivityPreviewByIdRef.current, + activityRefreshVersionBySessionId, }); - if (staleSessions.length === 0) { + if (staleTargets.length === 0) { return; } @@ -2165,21 +1232,17 @@ export function TeamWorkspaceBoard({ const prefetchPreviews = async () => { await Promise.allSettled( - staleSessions.map((session) => { + staleTargets.map((target) => { if (cancelled) { return Promise.resolve(); } - const refreshVersion = - activityRefreshVersionBySessionId[session.id] ?? 0; - const cachedPreview = - sessionActivityPreviewByIdRef.current[session.id]; return syncSessionActivityPreview( - session.id, - buildSessionActivityFingerprint(session), - refreshVersion, + target.sessionId, + target.fingerprint, + target.refreshVersion, { - force: (cachedPreview?.refreshVersion ?? 0) < refreshVersion, + force: true, }, ); }), @@ -2198,68 +1261,19 @@ export function TeamWorkspaceBoard({ syncSessionActivityPreview, ]); - const canvasSessionLanes = useMemo( + const canvasLanes = useMemo( () => - memberCanvasSessions.map((session) => { - const previewState = sessionActivityPreviewById[session.id] ?? null; - const mergedEntries = mergeSessionActivityEntries( - liveActivityBySessionId[session.id], - previewState?.entries, - ACTIVITY_TIMELINE_ENTRY_LIMIT, - ); - const cardActivityPreview = - buildActivityPreviewFromEntry(mergedEntries[0]) ?? - previewState?.preview ?? - null; - const meta = resolveStatusMeta(session.runtimeStatus); - const matchedRuntimeMemberId = resolveLaneMatchingRuntimeMemberId( - session, - runtimeMembers, - ); - const matchedPlannedRoleId = resolveLaneMatchingPlannedRoleId( - session, - normalizedSelectedTeamRoles, - ); - const presetLabel = session.teamPresetId - ? (getTeamPresetOption(session.teamPresetId)?.label ?? - session.teamPresetId) - : undefined; - - return { - id: session.id, - persistKey: `session:${session.id}`, - fallbackPersistKeys: [ - matchedRuntimeMemberId ? `runtime:${matchedRuntimeMemberId}` : null, - matchedPlannedRoleId ? `planned:${matchedPlannedRoleId}` : null, - ].filter(Boolean) as string[], - kind: "session" as const, - title: session.name, - summary: - session.taskSummary || - "暂时还没有任务摘要,打开详情后可查看完整上下文。", - badgeLabel: meta.label, - badgeClassName: meta.badgeClassName, - dotClassName: meta.dotClassName, - roleLabel: - session.blueprintRoleLabel || - resolveTeamWorkspaceRoleHintLabel(session.roleHint) || - undefined, - profileLabel: session.profileName || undefined, - presetLabel, - modelLabel: session.model || undefined, - statusHint: buildRuntimeDetailSummary(session), - updatedAtLabel: formatUpdatedAt(session.updatedAt), - skillLabels: (session.skills ?? []) - .slice(0, 4) - .map((skill) => buildSkillDisplayName(skill)), - session, - previewText: - cardActivityPreview || - buildSessionLaneEmptyState({ session, previewState }), - previewEntries: mergedEntries.slice(0, 3), - }; + buildTeamWorkspaceCanvasLanes({ + hasRealTeamGraph, + sessions: memberCanvasSessions, + runtimeMembers, + plannedRoles: normalizedSelectedTeamRoles, + liveActivityBySessionId, + previewBySessionId: sessionActivityPreviewById, + activityTimelineEntryLimit: ACTIVITY_TIMELINE_ENTRY_LIMIT, }), [ + hasRealTeamGraph, liveActivityBySessionId, memberCanvasSessions, normalizedSelectedTeamRoles, @@ -2267,69 +1281,6 @@ export function TeamWorkspaceBoard({ sessionActivityPreviewById, ], ); - - const canvasBlueprintLanes = useMemo(() => { - if (hasRealTeamGraph) { - return []; - } - - if (runtimeMembers.length > 0) { - return runtimeMembers.map((member) => { - const laneSummary = buildCanvasLaneTitleSummary(member); - const matchedPlannedRoleId = resolveRuntimeMemberMatchingPlannedRoleId( - member, - normalizedSelectedTeamRoles, - ); - return { - id: member.id, - persistKey: `runtime:${member.id}`, - fallbackPersistKeys: matchedPlannedRoleId - ? [`planned:${matchedPlannedRoleId}`] - : [], - kind: "runtime" as const, - title: member.label, - summary: laneSummary.summary, - badgeLabel: laneSummary.badgeLabel, - badgeClassName: laneSummary.badgeClassName, - dotClassName: laneSummary.dotClassName, - roleLabel: - resolveTeamWorkspaceRoleHintLabel(member.roleKey) || undefined, - profileLabel: undefined, - statusHint: laneSummary.statusHint, - updatedAtLabel: "等待成员加入", - skillLabels: [], - previewText: member.summary, - previewEntries: [], - }; - }); - } - - return normalizedSelectedTeamRoles.map((role) => { - const laneSummary = buildPlannedRoleLaneSummary(role); - return { - id: role.id, - persistKey: `planned:${role.id}`, - fallbackPersistKeys: [], - kind: "planned" as const, - title: role.label, - summary: laneSummary.summary, - badgeLabel: laneSummary.badgeLabel, - badgeClassName: laneSummary.badgeClassName, - dotClassName: laneSummary.dotClassName, - roleLabel: resolveTeamWorkspaceRoleHintLabel(role.roleKey) || undefined, - profileLabel: undefined, - statusHint: laneSummary.statusHint, - updatedAtLabel: "计划分工", - skillLabels: [], - previewText: role.summary, - previewEntries: [], - }; - }); - }, [hasRealTeamGraph, normalizedSelectedTeamRoles, runtimeMembers]); - - const canvasLanes = hasRealTeamGraph - ? canvasSessionLanes - : canvasBlueprintLanes; const canvasAutoLayoutViewportWidth = embedded ? Math.max(canvasViewportMetrics.width, 1240) : Math.max(canvasViewportMetrics.width, 1080); @@ -2981,88 +1932,40 @@ export function TeamWorkspaceBoard({ ], ); - const statusSummary = useMemo(() => { - const sessions = isChildSession - ? dedupeSessions([currentChildSession, ...visibleSessions]) - : visibleSessions; - - return sessions.reduce( - (summary, session) => { - const key = session.runtimeStatus ?? "idle"; - summary[key] = (summary[key] ?? 0) + 1; - return summary; - }, - {} as Record, - ); - }, [currentChildSession, isChildSession, visibleSessions]); - const railSessionNameById = useMemo( - () => new Map(railSessions.map((session) => [session.id, session.name])), - [railSessions], - ); - const waitableTeamSessions = useMemo( - () => railSessions.filter((session) => isWaitableTeamSession(session)), - [railSessions], - ); - const waitableTeamSessionIds = useMemo( - () => waitableTeamSessions.map((session) => session.id), - [waitableTeamSessions], + const sessionControlState = useMemo( + () => + buildTeamWorkspaceSessionControlState({ + visibleSessions, + railSessions, + currentChildSession, + isChildSession, + currentSessionId, + }), + [ + currentChildSession, + currentSessionId, + isChildSession, + railSessions, + visibleSessions, + ], ); + const statusSummary = sessionControlState.statusSummary; + const waitableTeamSessionIds = sessionControlState.waitableSessionIds; const canWaitAnyActiveTeamSession = Boolean( onWaitActiveTeamSessions && waitableTeamSessionIds.length > 1, ); - const visibleTeamWaitSummary = useMemo(() => { - if (!teamWaitSummary) { - return null; - } - - return teamWaitSummary.awaitedSessionIds.some((sessionId) => - railSessionNameById.has(sessionId), - ) - ? teamWaitSummary - : null; - }, [railSessionNameById, teamWaitSummary]); - const visibleTeamControlSummary = useMemo(() => { - if (!teamControlSummary) { - return null; - } - - return [ - ...teamControlSummary.requestedSessionIds, - ...teamControlSummary.affectedSessionIds, - ].some((sessionId) => railSessionNameById.has(sessionId)) - ? teamControlSummary - : null; - }, [railSessionNameById, teamControlSummary]); - const teamOperationEntries = useMemo( + const teamOperationState = useMemo( () => - buildTeamOperationDisplayEntries({ - sessionNameById: railSessionNameById, - teamWaitSummary: visibleTeamWaitSummary, - teamControlSummary: visibleTeamControlSummary, - }).filter( - (entry) => - !entry.targetSessionId || - railSessions.some((session) => session.id === entry.targetSessionId), - ), - [ - railSessionNameById, - railSessions, - visibleTeamControlSummary, - visibleTeamWaitSummary, - ], - ); - const completedTeamSessions = useMemo( - () => - railSessions.filter( - (session) => - session.id !== currentSessionId && isCompletedTeamSession(session), - ), - [currentSessionId, railSessions], - ); - const completedTeamSessionIds = useMemo( - () => completedTeamSessions.map((session) => session.id), - [completedTeamSessions], + buildVisibleTeamOperationState({ + railSessions, + teamWaitSummary, + teamControlSummary, + }), + [railSessions, teamControlSummary, teamWaitSummary], ); + const visibleTeamWaitSummary = teamOperationState.visibleTeamWaitSummary; + const teamOperationEntries = teamOperationState.entries; + const completedTeamSessionIds = sessionControlState.completedSessionIds; const canCloseCompletedTeamSessions = Boolean( onCloseCompletedTeamSessions && completedTeamSessionIds.length > 0, ); @@ -3278,53 +2181,14 @@ export function TeamWorkspaceBoard({ if (isEmptyShellState && !shellExpanded) { return ( -
-
-
-
- - {TEAM_WORKSPACE_SURFACE_TITLE} - - - {TEAM_WORKSPACE_REALTIME_BADGE_LABEL} - -
-
- - {runtimeFormationMeta?.title || "协作面板已就绪"} - - - {runtimeFormationMeta?.label || "还没有协作成员加入"} - -
-

- {hasRuntimeFormation - ? buildRuntimeFormationHint(dispatchPreviewState) - : "这里先保持简洁,避免遮挡消息区;只有真正需要协作分工时才会展开完整面板。"} -

- {hasRuntimeFormation - ? renderRuntimeFormationSummary() - : renderSelectedTeamPlanSummary()} -
- -
-
+ setShellExpanded(true)} + runtimeFormationDisplay={runtimeFormationDisplay} + selectedTeamPlanDisplay={selectedTeamPlanDisplay} + /> ); } @@ -3334,23 +2198,25 @@ export function TeamWorkspaceBoard({ ? detailExpanded || shellExpanded : false; const detailToggleLabel = detailVisible ? "收起细节" : "查看细节"; - const boardHeadline = - !hasRealTeamGraph && runtimeFormationMeta - ? runtimeFormationMeta.title - : buildBoardHeadline({ - hasRealTeamGraph, - isChildSession, - subagentParentContext, - totalTeamSessions, - }); - const boardHint = - !hasRealTeamGraph && hasRuntimeFormation - ? buildRuntimeFormationHint(dispatchPreviewState) - : buildBoardHint({ - hasRealTeamGraph, - isChildSession, - siblingCount, - }); + const boardChromeDisplay = buildTeamWorkspaceBoardChromeDisplayState({ + hasRealTeamGraph, + hasRuntimeFormation, + runtimeFormationTitle: hasRuntimeFormation + ? runtimeFormationDisplay.panelHeadline + : null, + runtimeFormationHint: runtimeFormationDisplay.hint, + isChildSession, + parentSessionName: subagentParentContext?.parent_session_name, + totalTeamSessions, + siblingCount, + selectedSession, + zoom: canvasLayoutState.viewport.zoom, + canWaitAnyActiveTeamSession, + waitableCount: waitableTeamSessionIds.length, + canCloseCompletedTeamSessions, + completedCount: completedTeamSessionIds.length, + statusSummary, + }); const detailSummary = selectedSession?.taskSummary || buildFallbackSummary({ @@ -3359,46 +2225,11 @@ export function TeamWorkspaceBoard({ selectedSession, }); const useCompactCanvasChrome = hasRealTeamGraph; - const runtimeDetailSummary = buildRuntimeDetailSummary(selectedSession); - const selectedPresetOption = getTeamPresetOption( - selectedSession?.teamPresetId, - ); - const selectedSkills = selectedSession?.skills ?? []; - const selectedMetadata = [ - selectedSession?.blueprintRoleLabel - ? `分工 ${selectedSession.blueprintRoleLabel}` - : null, - selectedSession?.sessionType - ? resolveSessionTypeLabel(selectedSession.sessionType) - : null, - selectedSession?.providerName - ? `服务 ${selectedSession.providerName}` - : null, - selectedSession?.model ? `模型 ${selectedSession.model}` : null, - selectedSession?.originTool ? `来源 ${selectedSession.originTool}` : null, - selectedSession?.createdFromTurnId - ? `来自之前的任务 ${selectedSession.createdFromTurnId}` - : null, - selectedSession && - (selectedSession.teamQueuedCount ?? selectedSession.queuedTurnCount ?? 0) > - 0 - ? `等待中 ${selectedSession.teamQueuedCount ?? selectedSession.queuedTurnCount}` - : null, - selectedSession?.teamActiveCount !== undefined && - selectedSession?.teamParallelBudget !== undefined - ? `处理中 ${selectedSession.teamActiveCount}/${selectedSession.teamParallelBudget}` - : null, - selectedSession?.providerParallelBudget === 1 && - selectedSession?.providerConcurrencyGroup - ? resolveTeamWorkspaceStableProcessingLabel() - : null, - selectedSession?.latestTurnStatus - ? `最近进展 ${resolveStatusMeta(selectedSession.latestTurnStatus).label}` - : null, - isChildSession && subagentParentContext?.parent_session_name - ? `来自 ${subagentParentContext.parent_session_name}` - : null, - ].filter(Boolean) as string[]; + const selectedSessionDetailDisplay = buildSelectedSessionDetailDisplayState({ + selectedSession, + isChildSession, + parentSessionName: subagentParentContext?.parent_session_name, + }); const boardShellClassName = cn( embedded ? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-none border-0 bg-transparent shadow-none" @@ -3452,766 +2283,99 @@ export function TeamWorkspaceBoard({ "mt-3 rounded-[16px] border border-slate-200 bg-white p-3"; const inlineTimelineEntryClassName = "rounded-[14px] border border-slate-200 bg-white p-3"; - const compactCanvasSummaryChipClassName = - "rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500"; - const compactCanvasMutedChipClassName = - "rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500"; - const compactCanvasFocusLabel = selectedSession - ? `焦点 ${selectedSession.name}` - : "等待成员接入"; - const compactBoardHeadline = - useCompactCanvasChrome && !isChildSession && totalTeamSessions > 0 - ? `${totalTeamSessions} 位成员协作中` - : boardHeadline; - const renderSelectedSessionInlineDetail = () => { - if (!selectedSession) { + const renderCanvasSelectedInlineDetail = (lane: TeamWorkspaceCanvasLane) => { + if (!selectedSession || lane.session?.id !== selectedSession.id) { return null; } return ( -
-
-
-
- - 当前查看 - {selectedSession.isCurrent ? ( - - 当前对话 - - ) : null} -
-

- {detailSummary} -

-
- -
- {canResumeSelectedSession ? ( - - ) : null} - {canStopSelectedSession ? ( - - ) : null} - {canOpenSelectedSession ? ( - - ) : null} -
-
- - {runtimeDetailSummary ? ( -
- - {runtimeDetailSummary} - -
- ) : null} - {selectedSession?.queueReason ? ( -
- {selectedSession.queueReason} -
- ) : null} - - {selectedSession?.profileName || - selectedSession?.teamPresetId || - selectedSession?.theme || - selectedSession?.outputContract || - selectedSkills.length > 0 ? ( -
-
- - 协作设置 -
-
- {selectedPresetOption ? ( - - 预设 {selectedPresetOption.label} - - ) : selectedSession?.teamPresetId ? ( - - 预设 {selectedSession.teamPresetId} - - ) : null} - {selectedSession?.profileName ? ( - - 风格 {selectedSession.profileName} - - ) : null} - {resolveTeamWorkspaceRoleHintLabel(selectedSession?.roleKey) ? ( - - 分工{" "} - {resolveTeamWorkspaceRoleHintLabel(selectedSession?.roleKey)} - - ) : null} - {selectedSession?.theme ? ( - - 主题 {selectedSession.theme} - - ) : null} -
- {selectedSession?.outputContract ? ( -

- {selectedSession.outputContract} -

- ) : null} - {selectedSkills.length > 0 ? ( -
- {selectedSkills.map((skill) => ( - - {buildSkillDisplayName(skill)} - - ))} -
- ) : null} -
- ) : null} - - {canWaitSelectedSession || canSendSelectedSessionInput ? ( -
-
- - 继续协作 - {canWaitSelectedSession ? ( - - 可直接查看结果 - - ) : null} -
- {canWaitSelectedSession ? ( -
- - - 仅在当前内容确实依赖这位成员结果时使用。 - -
- ) : null} - {canSendSelectedSessionInput ? ( -
-