From 2888352a00ced4bf158e65f53df30a6c62bbd92d Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 8 Mar 2026 07:53:37 +0800 Subject: [PATCH] feat: release v0.81.0 with full pending changes --- IMPLEMENTATION_PLAN.md | 175 + RELEASE_NOTES.md | 66 +- package.json | 5 +- scripts/social-workbench-e2e-smoke.mjs | 416 ++ src-tauri/Cargo.lock | 32 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/core/src/config/types.rs | 40 + .../crates/core/src/database/dao/agent_run.rs | 70 + .../crates/core/src/session_files/storage.rs | 37 + src-tauri/crates/core/src/workspace/types.rs | 225 +- .../providers/src/providers/openai_custom.rs | 35 +- src-tauri/crates/services/src/live_sync.rs | 2 +- .../crates/services/src/machine_id_service.rs | 4 - .../services/src/provider_pool_service.rs | 205 +- .../src/connections/wsl_connection.rs | 2 +- .../websocket/src/handlers/rpc_handler.rs | 4 + .../broadcast_generate/SKILL.md | 32 + .../default-skills/cover_generate/SKILL.md | 33 + .../default-skills/image_generate/SKILL.md | 32 + .../resources/default-skills/library/SKILL.md | 44 + .../modal_resource_search/SKILL.md | 32 + .../default-skills/research/SKILL.md | 49 + .../social_post_with_cover/SKILL.md | 104 + .../default-skills/typesetting/SKILL.md | 33 + .../default-skills/url_parse/SKILL.md | 32 + .../default-skills/video_generate/SKILL.md | 34 + src-tauri/src/app/bootstrap.rs | 15 + src-tauri/src/app/runner.rs | 11 + src-tauri/src/app/setup.rs | 12 + src-tauri/src/commands/aster_agent_cmd.rs | 1406 +++++- src-tauri/src/commands/content_cmd.rs | 167 + src-tauri/src/commands/document_import_cmd.rs | 120 + .../commands/ecommerce_review_reply_cmd.rs | 3 + src-tauri/src/commands/execution_run_cmd.rs | 538 ++- src-tauri/src/commands/image_upload_cmd.rs | 99 + src-tauri/src/commands/mod.rs | 3 + src-tauri/src/commands/session_files_cmd.rs | 11 + src-tauri/src/commands/skill_exec_cmd.rs | 648 ++- src-tauri/src/commands/theme_context_cmd.rs | 522 +++ src-tauri/src/commands/workspace_cmd.rs | 2 + .../src/services/execution_tracker_service.rs | 10 + src-tauri/src/skills/default_skills.rs | 160 + src-tauri/src/skills/mod.rs | 2 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 92 +- src/components/AppSidebar.tsx | 2 +- .../chat/components/ChatSidebar.test.tsx | 98 + .../agent/chat/components/ChatSidebar.tsx | 57 +- .../agent/chat/components/EmptyState.test.tsx | 127 +- .../agent/chat/components/EmptyState.tsx | 37 +- .../Inputbar/components/CharacterMention.tsx | 8 + .../Inputbar/components/InputbarCore.test.tsx | 101 + .../Inputbar/components/InputbarCore.tsx | 268 +- .../Inputbar/components/InputbarTools.tsx | 96 +- .../chat/components/Inputbar/index.test.tsx | 311 +- .../agent/chat/components/Inputbar/index.tsx | 760 +++- .../agent/chat/components/Inputbar/styles.ts | 100 +- .../chat/components/MarkdownRenderer.tsx | 19 - .../agent/chat/components/MessageList.tsx | 5 +- .../components/ThemeWorkbenchSidebar.test.tsx | 939 ++++ .../chat/components/ThemeWorkbenchSidebar.tsx | 2880 ++++++++++++ .../ThemeWorkbenchSkillsPanel.test.tsx | 137 + .../components/ThemeWorkbenchSkillsPanel.tsx | 656 +++ src/components/agent/chat/hooks/index.ts | 2 + .../agent/chat/hooks/skillCommand.test.ts | 289 ++ .../agent/chat/hooks/skillCommand.ts | 55 +- .../agent/chat/hooks/useAgentChat.ts | 45 +- .../chat/hooks/useAsterAgentChat.test.tsx | 143 + .../agent/chat/hooks/useAsterAgentChat.ts | 79 +- .../agent/chat/hooks/useContentSync.test.tsx | 120 + .../agent/chat/hooks/useContentSync.ts | 69 +- .../hooks/useThemeContextWorkspace.test.tsx | 415 ++ .../chat/hooks/useThemeContextWorkspace.ts | 1246 ++++++ .../chat/hooks/useTopicBranchBoard.test.tsx | 189 + .../agent/chat/hooks/useTopicBranchBoard.ts | 206 + src/components/agent/chat/index.test.tsx | 1361 +++++- src/components/agent/chat/index.tsx | 3942 +++++++++++++++-- src/components/agent/chat/types.ts | 2 + .../agent/chat/utils/contextSearch.test.ts | 44 + .../agent/chat/utils/contextSearch.ts | 283 ++ .../chat/utils/extractDocumentContent.test.ts | 136 + .../chat/utils/taskFileCanvasSync.test.ts | 67 + .../agent/chat/utils/taskFileCanvasSync.ts | 70 + src/components/artifact/canvasAdapterUtils.ts | 2 +- .../a2ui/components/form/TextField.tsx | 64 +- .../content-creator/canvas/CanvasFactory.tsx | 90 +- .../content-creator/canvas/canvasUtils.ts | 2 +- .../canvas/document/ContentReviewPanel.tsx | 800 ++++ .../canvas/document/DocumentCanvas.tsx | 935 +++- .../canvas/document/DocumentToolbar.tsx | 790 +++- .../document/contentReviewExperts.test.ts | 22 + .../canvas/document/contentReviewExperts.ts | 108 + .../canvas/document/editor/BubbleToolbar.tsx | 143 +- .../canvas/document/editor/NotionEditor.tsx | 462 +- .../canvas/document/editor/editor-styles.css | 97 +- .../canvas/document/editor/index.ts | 1 + .../document/hooks/useDocumentCanvas.ts | 37 +- .../content-creator/canvas/document/index.tsx | 6 + .../content-creator/canvas/document/types.ts | 176 +- .../utils/autoContinueSettings.test.ts | 65 + .../document/utils/autoContinueSettings.ts | 92 + .../canvas/document/utils/exportDocument.ts | 143 + .../canvas/music/MusicCanvas.tsx | 4 - .../canvas/novel/NovelCanvas.tsx | 4 - .../canvas/poster/PosterCanvas.tsx | 5 +- .../canvas/script/ScriptCanvas.tsx | 5 +- .../canvas/video/VideoCanvas.tsx | 4 - .../LayoutTransition/LayoutTransition.tsx | 4 +- .../useLayoutTransition.test.tsx | 70 + .../LayoutTransition/useLayoutTransition.ts | 5 +- .../content-creator/utils/systemPrompt.ts | 3 + .../image-gen/ImageGenPage.test.tsx | 54 +- src/components/image-gen/ImageGenPage.tsx | 13 +- .../image-gen/tabs/AiImageGenTab.tsx | 40 +- .../image-gen/useImageGen.preference.test.tsx | 129 + src/components/image-gen/useImageGen.ts | 126 +- .../input-kit/BaseComposer.test.tsx | 20 + src/components/input-kit/BaseComposer.tsx | 21 +- src/components/input-kit/ModelSelector.tsx | 52 +- src/components/layout/CrashRecoveryPanel.tsx | 8 +- src/components/memory/MemoryPage.tsx | 18 +- src/components/plugins/PluginsPage.tsx | 12 +- src/components/projects/tabs/SettingsTab.tsx | 594 ++- src/components/resources/ResourcesPage.tsx | 15 +- src/components/settings-v2/_layout/index.tsx | 53 +- .../agent/image-gen/index.test.tsx | 43 + .../settings-v2/agent/image-gen/index.tsx | 188 +- .../agent/shared/MediaPreferenceSection.tsx | 167 + .../agent/video-gen/index.test.tsx | 129 + .../settings-v2/agent/video-gen/index.tsx | 231 + .../settings-v2/agent/voice/index.test.tsx | 143 + .../settings-v2/agent/voice/index.tsx | 174 +- .../settings-v2/hooks/useSettingsCategory.ts | 9 +- .../settings-v2/system/developer/index.tsx | 5 +- .../settings-v2/system/experimental/index.tsx | 5 +- src/components/tools/ToolsPage.tsx | 14 - src/components/ui/dropdown-menu.tsx | 51 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 15 +- .../workspace/WorkbenchPage.test.tsx | 721 ++- src/components/workspace/WorkbenchPage.tsx | 109 +- .../hooks/useCreationDialogs.test.tsx | 270 +- .../workspace/hooks/useCreationDialogs.ts | 411 +- .../hooks/useWorkbenchController.test.tsx | 17 +- .../workspace/hooks/useWorkbenchController.ts | 116 +- .../hooks/useWorkbenchNavigation.test.tsx | 40 - .../workspace/hooks/useWorkbenchNavigation.ts | 21 +- .../hooks/useWorkbenchProjectData.test.tsx | 24 + .../hooks/useWorkbenchProjectData.ts | 2 +- .../workspace/panels/WorkbenchMainContent.tsx | 75 +- .../panels/WorkbenchRightRail.test.tsx | 104 + .../workspace/panels/WorkbenchRightRail.tsx | 3445 +++++++++++++- src/components/workspace/panels/index.ts | 15 +- .../createConfirmationService.test.ts | 99 + .../services/createConfirmationService.ts | 156 + .../workspace/shell/WorkspaceTopbar.tsx | 75 +- .../utils/createConfirmationPolicy.ts | 253 ++ src/hooks/useApiKeyProvider.ts | 152 +- src/hooks/useGlobalMediaGenerationDefaults.ts | 48 + src/hooks/useTauri.ts | 14 + src/hooks/useWorkspace.ts | 11 +- src/i18n/I18nPatchProvider.tsx | 126 +- src/i18n/dom-replacer.ts | 249 +- src/lib/api/agent.ts | 10 + src/lib/api/document-export.ts | 11 + src/lib/api/executionRun.ts | 51 + src/lib/api/project.ts | 22 + src/lib/api/session-files.ts | 101 + src/lib/crashDiagnostic.ts | 25 + src/lib/documentEditorFocusEvents.ts | 35 + src/lib/imageGeneration.test.ts | 55 + src/lib/imageGeneration.ts | 149 + src/lib/mediaGeneration.test.ts | 118 + src/lib/mediaGeneration.ts | 292 ++ src/lib/perfDebug.ts | 52 + src/lib/tauri-mock/core.ts | 8 + src/stores/useWorkbenchStore.ts | 114 +- src/types/page.ts | 6 + src/types/project.ts | 3 + src/types/settings.ts | 2 + src/types/workspace.ts | 17 + 181 files changed, 32791 insertions(+), 2237 deletions(-) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 scripts/social-workbench-e2e-smoke.mjs create mode 100644 src-tauri/resources/default-skills/broadcast_generate/SKILL.md create mode 100644 src-tauri/resources/default-skills/cover_generate/SKILL.md create mode 100644 src-tauri/resources/default-skills/image_generate/SKILL.md create mode 100644 src-tauri/resources/default-skills/library/SKILL.md create mode 100644 src-tauri/resources/default-skills/modal_resource_search/SKILL.md create mode 100644 src-tauri/resources/default-skills/research/SKILL.md create mode 100644 src-tauri/resources/default-skills/social_post_with_cover/SKILL.md create mode 100644 src-tauri/resources/default-skills/typesetting/SKILL.md create mode 100644 src-tauri/resources/default-skills/url_parse/SKILL.md create mode 100644 src-tauri/resources/default-skills/video_generate/SKILL.md create mode 100644 src-tauri/src/commands/document_import_cmd.rs create mode 100644 src-tauri/src/commands/image_upload_cmd.rs create mode 100644 src-tauri/src/commands/theme_context_cmd.rs create mode 100644 src-tauri/src/skills/default_skills.rs create mode 100644 src/components/agent/chat/components/ChatSidebar.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx create mode 100644 src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx create mode 100644 src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx create mode 100644 src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.test.tsx create mode 100644 src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.tsx create mode 100644 src/components/agent/chat/hooks/skillCommand.test.ts create mode 100644 src/components/agent/chat/hooks/useContentSync.test.tsx create mode 100644 src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx create mode 100644 src/components/agent/chat/hooks/useThemeContextWorkspace.ts create mode 100644 src/components/agent/chat/hooks/useTopicBranchBoard.test.tsx create mode 100644 src/components/agent/chat/hooks/useTopicBranchBoard.ts create mode 100644 src/components/agent/chat/utils/contextSearch.test.ts create mode 100644 src/components/agent/chat/utils/contextSearch.ts create mode 100644 src/components/agent/chat/utils/extractDocumentContent.test.ts create mode 100644 src/components/agent/chat/utils/taskFileCanvasSync.test.ts create mode 100644 src/components/agent/chat/utils/taskFileCanvasSync.ts create mode 100644 src/components/content-creator/canvas/document/ContentReviewPanel.tsx create mode 100644 src/components/content-creator/canvas/document/contentReviewExperts.test.ts create mode 100644 src/components/content-creator/canvas/document/contentReviewExperts.ts create mode 100644 src/components/content-creator/canvas/document/utils/autoContinueSettings.test.ts create mode 100644 src/components/content-creator/canvas/document/utils/autoContinueSettings.ts create mode 100644 src/components/content-creator/canvas/document/utils/exportDocument.ts create mode 100644 src/components/content-creator/core/LayoutTransition/useLayoutTransition.test.tsx create mode 100644 src/components/image-gen/useImageGen.preference.test.tsx create mode 100644 src/components/settings-v2/agent/shared/MediaPreferenceSection.tsx create mode 100644 src/components/settings-v2/agent/video-gen/index.test.tsx create mode 100644 src/components/settings-v2/agent/video-gen/index.tsx create mode 100644 src/components/settings-v2/agent/voice/index.test.tsx create mode 100644 src/components/workspace/panels/WorkbenchRightRail.test.tsx create mode 100644 src/components/workspace/services/createConfirmationService.test.ts create mode 100644 src/components/workspace/services/createConfirmationService.ts create mode 100644 src/components/workspace/utils/createConfirmationPolicy.ts create mode 100644 src/hooks/useGlobalMediaGenerationDefaults.ts create mode 100644 src/lib/api/document-export.ts create mode 100644 src/lib/documentEditorFocusEvents.ts create mode 100644 src/lib/imageGeneration.test.ts create mode 100644 src/lib/imageGeneration.ts create mode 100644 src/lib/mediaGeneration.test.ts create mode 100644 src/lib/mediaGeneration.ts create mode 100644 src/lib/perfDebug.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..549783eec --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,175 @@ +# 主题工作台功能完善计划 + +## 目标 +完善主题工作台(Theme Workbench)的内容创建功能,增加**添加图片**和**导入文稿**功能。 + +## 用户需求 +1. ✅ 添加图片功能(前后端完整实现) +2. ✅ 导入文稿功能(前后端完整实现) +3. ✅ 保留现有的版本快照和分支话题功能 + +## 当前状态分析 + +### 已实现功能 +1. ✅ 基础版本快照创建(handleCreateVersionSnapshot) +2. ✅ 上下文管理系统(useThemeContextWorkspace) +3. ✅ 内容模板系统(tech-sharing, trending-topic等) +4. ✅ 侧边栏UI(ThemeWorkbenchSidebar) +5. ✅ 文件上传基础设施(useMaterials hook) + +### 待实现功能 +1. ❌ "+"按钮的下拉菜单(添加图片、导入文稿选项) +2. ❌ 图片上传和插入功能(前后端) +3. ❌ 文稿导入和解析功能(前后端) + +## 实现阶段 + +### Stage 1: 增强"+"按钮UI +**目标**: 将单一按钮改为下拉菜单,支持多种创建选项 + +**Success Criteria**: +- "+"按钮点击后显示下拉菜单 +- 菜单包含:创建版本快照、添加图片、导入文稿 +- 菜单项点击后触发相应功能 + +**实现步骤**: +1. 修改 ThemeWorkbenchSidebar.tsx,添加 DropdownMenu +2. 添加菜单项:创建版本快照、添加图片、导入文稿 +3. 定义回调函数接口 + +**文件修改**: +- `src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx` + +**Status**: Not Started + +### Stage 2: 实现添加图片功能(前端) +**目标**: 用户可以选择图片并插入到文档中 + +**Success Criteria**: +- 点击"添加图片"打开文件选择器 +- 支持常见图片格式(jpg, png, gif, webp) +- 图片上传后显示在文档中 +- 提供上传进度反馈 + +**实现步骤**: +1. 在 ThemeWorkbenchSidebar 中添加 onAddImage 回调 +2. 在主组件中实现 handleAddImage 函数 +3. 调用文件选择器API +4. 调用后端上传接口 +5. 将图片URL插入到文档画布 + +**文件修改**: +- `src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx` +- `src/components/agent/chat/index.tsx` +- `src/lib/api/session-files.ts` (可能需要新增) + +**Status**: Not Started + +### Stage 3: 实现添加图片功能(后端) +**目标**: 后端接收图片上传请求并存储 + +**Success Criteria**: +- 接收图片文件上传 +- 验证文件类型和大小 +- 存储到 session files 或 materials +- 返回图片访问URL + +**实现步骤**: +1. 创建 Tauri command: `upload_image_to_session` +2. 实现图片文件验证逻辑 +3. 存储图片到本地或云端 +4. 返回图片URL + +**文件修改**: +- `src-tauri/src/commands/session_files_cmd.rs` (或新建 image_cmd.rs) +- `src-tauri/crates/core/src/session_files/storage.rs` + +**Status**: Not Started + +### Stage 4: 实现导入文稿功能(前端) +**目标**: 用户可以导入外部文稿到文档画布 + +**Success Criteria**: +- 点击"导入文稿"打开文件选择器 +- 支持 .md, .txt, .docx 格式 +- 文稿内容解析后加载到编辑器 +- 提供导入进度反馈 + +**实现步骤**: +1. 在 ThemeWorkbenchSidebar 中添加 onImportDocument 回调 +2. 在主组件中实现 handleImportDocument 函数 +3. 调用文件选择器API +4. 调用后端解析接口 +5. 将解析后的内容加载到文档画布 + +**文件修改**: +- `src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx` +- `src/components/agent/chat/index.tsx` +- `src/lib/api/document-import.ts` (新建) + +**Status**: Not Started + +### Stage 5: 实现导入文稿功能(后端) +**目标**: 后端解析不同格式的文稿文件 + +**Success Criteria**: +- 接收文件路径或文件内容 +- 解析 Markdown (.md) +- 解析纯文本 (.txt) +- 解析 Word 文档 (.docx) +- 返回统一的 Markdown 格式 + +**实现步骤**: +1. 创建 Tauri command: `import_document` +2. 实现 Markdown 解析器 +3. 实现纯文本解析器 +4. 实现 Word 文档解析器(使用 docx-rs 或类似库) +5. 统一输出格式 + +**文件修改**: +- `src-tauri/src/commands/document_import_cmd.rs` (新建) +- `src-tauri/crates/services/src/document_import_service.rs` (新建) +- `src-tauri/Cargo.toml` (添加依赖) + +**Status**: Not Started + +## 技术细节 + +### 前端技术栈 +- React + TypeScript +- styled-components +- Tauri API (文件选择器) +- lucide-react (图标) + +### 后端技术栈 +- Rust + Tauri +- docx-rs (Word 文档解析) +- markdown (Markdown 解析) +- tokio (异步IO) + +### 文件格式支持 +**图片格式**: +- JPEG (.jpg, .jpeg) +- PNG (.png) +- GIF (.gif) +- WebP (.webp) + +**文稿格式**: +- Markdown (.md) +- 纯文本 (.txt) +- Word 文档 (.docx) + +### 依赖关系 +- Stage 1 独立实现(UI基础) +- Stage 2 依赖 Stage 1 和 Stage 3 +- Stage 3 独立实现(后端图片) +- Stage 4 依赖 Stage 1 和 Stage 5 +- Stage 5 独立实现(后端文稿) + +## 注意事项 +1. 文件大小限制:图片 < 10MB,文稿 < 5MB +2. 安全性:验证文件类型,防止恶意文件上传 +3. 错误处理:提供清晰的错误提示 +4. 用户体验:显示上传/导入进度 +5. 性能优化:大文件异步处理 +6. 兼容性:确保与现有功能不冲突 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0f11bceaa..92d20a95d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,36 +1,52 @@ -## ProxyCast v0.80.0 +## ProxyCast v0.81.0 ### ✨ 新功能 -- 新增 Gateway 网关模块,支持隧道和频道管理 (gateway crate + gateway_tunnel_cmd + gateway_channel_cmd) -- 新增 Agent 工具调用策略管理 (request_tool_policy.rs) -- 新增 Aster 会话恢复机制 (asterSessionRecovery) -- 新增频道日志尾部面板和日志过滤功能 (ChannelLogTailPanel + channel-log-filter) -- 新增 Gateway 隧道 Webhook 使用文档 -- Agent 聊天增强:扩展 useAsterAgentChat hooks 功能 -- 新增 useTauri hooks 扩展,提供更多 Tauri 桥接能力 -- 新增频道 API 接口 (channels.ts) -- 数据库新增 v3 迁移支持 + +- 主题工作台(Theme Workbench)完整实现:双区域架构(对话区 + 画布区)、上下文管理、版本快照、分支话题 +- 主题工作台侧边栏:上下文列表、编排工作台、网络检索、技能面板 +- 主题工作台技能面板(ThemeWorkbenchSkillsPanel):支持技能快捷调用 +- 文档画布增强:内容审阅面板、自动续写、文本风格化、版本管理 +- 文档导出功能 +- 图片上传到会话和文稿导入功能 +- 默认技能系统:内置 social_post_with_cover、video_generate、cover_generate、image_generate、library、research、typesetting 等技能 +- 主题上下文工作区 hook:管理上下文筛选、激活、检索 +- 话题分支看板 hook:支持话题分支创建和切换 +- 内容同步增强:支持主题工作台文档状态同步 +- 上下文搜索工具:支持素材库和网络检索 +- 任务文件画布同步 +- 媒体生成统一接口和全局默认设置 +- 视频生成设置页面 +- 语音设置页面增强 +- 工作台右侧面板大幅扩展:支持主题工作台集成 +- Provider Pool 服务增强:优化模型路由和池管理 +- Execution Run 命令扩展:支持主题工作台状态查询 +- 文档编辑器焦点事件管理 ### 🐛 修复 -- 修复 CI 构建中 app-version 模块的 shebang 解析问题 -- 修复 CI release 流程中 tauri action 的版本兼容性 -- 修复 CI release 的 projectPath 配置 + +- 修复主题工作台内容提取逻辑:AI 普通对话不再被误判为文档内容写入画布 +- 修复 default_skills 测试断言逻辑 +- 修复 content_cmd 测试中 i32 溢出问题 +- 修复 WSL 连接和 live_sync 中的小问题 ### 🔧 优化与重构 -- 重构 request_tool_policy_prompt_service,精简代码 -- 优化 scheduler executor 执行逻辑 -- 优化 websocket RPC handler,扩展协议支持 -- 优化 terminal PTY 会话和本地连接管理 -- 扩展 core 配置类型,新增 GatewayConfig -- 增强 agent store 状态管理 -- 优化 aster agent 命令处理 -- 改进 config observer 和 bootstrap 流程 + +- 上下文列表 UI 优化:改进间距、字体、自定义 checkbox 样式、选中状态视觉反馈 +- InputbarCore 重构:增强输入栏功能和样式 +- LayoutTransition 优化 +- DocumentToolbar / BubbleToolbar / NotionEditor 增强 +- CanvasFactory 重构:支持更多画布类型 +- DropdownMenu / Popover / Select UI 组件优化 +- I18n 补丁提供者和 DOM 替换器优化 +- CrashRecoveryPanel 改进 +- WorkbenchStore 扩展 +- 系统提示词增强 ### 📦 其他 -- 更新依赖版本 (pnpm-lock.yaml, Cargo.lock) -- 新增 scheduler 依赖 -- 开发者设置页面微调 + +- 新增大量测试覆盖:ThemeWorkbenchSidebar、useThemeContextWorkspace、useTopicBranchBoard、useContentSync、skillCommand、contextSearch、taskFileCanvasSync、extractDocumentContent 等 +- E2E 冒烟测试脚本 --- -**完整变更**: v0.79.0...v0.80.0 +**完整变更**: v0.80.0...v0.81.0 diff --git a/package.json b/package.json index ac885d10a..986629aa6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.80.0", + "version": "0.81.0", "type": "module", "repository": { "type": "git", @@ -31,7 +31,8 @@ "ai-verify:level2": "tsx scripts/ai-code-verify.ts --level 2", "ai-verify:prompt": "tsx scripts/ai-code-verify.ts --generate-prompt", "ai-verify:file": "tsx scripts/ai-code-verify.ts --files", - "bridge:e2e": "node scripts/chrome-bridge-e2e.mjs" + "bridge:e2e": "node scripts/chrome-bridge-e2e.mjs", + "smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs" }, "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/scripts/social-workbench-e2e-smoke.mjs b/scripts/social-workbench-e2e-smoke.mjs new file mode 100644 index 000000000..1a2a3f416 --- /dev/null +++ b/scripts/social-workbench-e2e-smoke.mjs @@ -0,0 +1,416 @@ +#!/usr/bin/env node + +/** + * 主题工作台社媒链路联调脚本 + * + * 用法示例: + * node scripts/social-workbench-e2e-smoke.mjs --session-id + * node scripts/social-workbench-e2e-smoke.mjs --session-id --content-id + * + * 前置条件: + * 1. ProxyCast 已运行(Dev Bridge: http://127.0.0.1:3030/invoke) + * 2. 该 session 已在 UI 中实际触发过社媒生成 + */ + +const BRIDGE_URL = "http://127.0.0.1:3030/invoke"; + +function printUsage() { + console.log(` +用法: + node scripts/social-workbench-e2e-smoke.mjs --session-id [--content-id ] [--expected-provider ] [--expected-model ] [--timeout-ms ] [--interval-ms ] + +参数: + --session-id 必填,会话 ID + --content-id 可选,文稿 ID(用于校验版本状态) + --expected-provider 可选,期望命中的 provider(校验 run metadata 中的 requested_provider / provider_override) + --expected-model 可选,期望命中的模型(校验 run metadata 中的 requested_model / model_override) + --timeout-ms 可选,等待终态超时(默认 60000) + --interval-ms 可选,轮询间隔(默认 1000) + --help 显示帮助 +`); +} + +function parseArgs(argv) { + const result = { + sessionId: "", + contentId: "", + expectedProvider: "", + expectedModel: "", + timeoutMs: 60_000, + intervalMs: 1_000, + help: false, + }; + + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--help" || token === "-h") { + result.help = true; + continue; + } + if (token === "--session-id") { + result.sessionId = String(argv[i + 1] || "").trim(); + i += 1; + continue; + } + if (token === "--content-id") { + result.contentId = String(argv[i + 1] || "").trim(); + i += 1; + continue; + } + if (token === "--expected-provider") { + result.expectedProvider = String(argv[i + 1] || "").trim(); + i += 1; + continue; + } + if (token === "--expected-model") { + result.expectedModel = String(argv[i + 1] || "").trim(); + i += 1; + continue; + } + if (token === "--timeout-ms") { + const value = Number(argv[i + 1]); + if (Number.isFinite(value) && value > 0) { + result.timeoutMs = value; + } + i += 1; + continue; + } + if (token === "--interval-ms") { + const value = Number(argv[i + 1]); + if (Number.isFinite(value) && value > 0) { + result.intervalMs = value; + } + i += 1; + } + } + + return result; +} + +async function invoke(cmd, args) { + const response = await fetch(BRIDGE_URL, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + }); + + 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; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function parseRunMetadata(raw) { + if (!raw) { + return null; + } + if (typeof raw === "object") { + return raw; + } + if (typeof raw !== "string") { + return null; + } + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function normalizeNonEmptyString(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim(); +} + +function pickMetadataString(metadata, keys) { + if (!metadata || typeof metadata !== "object") { + return ""; + } + + for (const key of keys) { + const value = normalizeNonEmptyString(metadata[key]); + if (value) { + return value; + } + } + + return ""; +} + +function pickArtifactPaths(metadata) { + if (!metadata || typeof metadata !== "object") { + return []; + } + const paths = metadata.artifact_paths; + if (!Array.isArray(paths)) { + return []; + } + return paths.filter((item) => typeof item === "string" && item.trim()); +} + +function pickPathBySuffix(paths, suffix) { + return paths.find((item) => item.toLowerCase().endsWith(suffix)) || ""; +} + +async function waitTerminalState(sessionId, timeoutMs, intervalMs) { + const startedAt = Date.now(); + let latest = null; + + while (Date.now() - startedAt < timeoutMs) { + const state = await invoke("execution_run_get_theme_workbench_state", { + sessionId, + limit: 10, + }); + latest = state; + + const terminal = state?.latest_terminal; + if (terminal && state?.run_state !== "auto_running") { + return state; + } + + await sleep(intervalMs); + } + + throw new Error( + `等待主题工作台终态超时(${timeoutMs}ms),最后状态: ${JSON.stringify(latest)}`, + ); +} + +async function verifySessionArtifacts(sessionId, artifactPaths) { + const files = await invoke("session_files_list_files", { sessionId }); + const fileNames = Array.isArray(files) + ? files.map((item) => item?.name).filter((item) => typeof item === "string") + : []; + + const missing = artifactPaths.filter((path) => !fileNames.includes(path)); + assert( + missing.length === 0, + `会话文件缺失产物: ${missing.join(", ")}`, + ); + + const articlePath = pickPathBySuffix(artifactPaths, ".md"); + const coverPath = pickPathBySuffix(artifactPaths, ".cover.json"); + const publishPackPath = pickPathBySuffix(artifactPaths, ".publish-pack.json"); + + assert(articlePath, "缺少主稿路径(*.md)"); + assert(coverPath, "缺少封面元数据路径(*.cover.json)"); + assert(publishPackPath, "缺少发布包路径(*.publish-pack.json)"); + + const articleContent = await invoke("session_files_read_file", { + sessionId, + fileName: articlePath, + }); + assert(typeof articleContent === "string", "主稿内容读取失败"); + assert(articleContent.includes("![封面图]("), "主稿缺少封面图占位/链接"); + assert(articleContent.includes("## 配图说明"), "主稿缺少配图说明章节"); + + const coverContent = await invoke("session_files_read_file", { + sessionId, + fileName: coverPath, + }); + const coverJson = JSON.parse(String(coverContent)); + assert(typeof coverJson.cover_url === "string", "cover.json 缺少 cover_url"); + assert(typeof coverJson.status === "string", "cover.json 缺少 status"); + + const publishPackContent = await invoke("session_files_read_file", { + sessionId, + fileName: publishPackPath, + }); + const publishPackJson = JSON.parse(String(publishPackContent)); + assert( + publishPackJson.article_path === articlePath, + "publish-pack.json article_path 与主稿路径不一致", + ); + assert( + publishPackJson.cover_meta_path === coverPath, + "publish-pack.json cover_meta_path 与封面元数据路径不一致", + ); + + return { + articlePath, + coverPath, + publishPackPath, + }; +} + +async function verifyContentVersionState(contentId, expectedRunId, timeoutMs, intervalMs) { + const startedAt = Date.now(); + let latest = null; + + while (Date.now() - startedAt < timeoutMs) { + const state = await invoke("content_get_theme_workbench_document_state", { + id: contentId, + }); + latest = state; + + if (state && Array.isArray(state.versions)) { + const matched = state.versions.find((item) => item?.id === expectedRunId); + if (matched) { + return { + currentVersionId: state.current_version_id, + matchedStatus: matched.status || null, + }; + } + } + + await sleep(intervalMs); + } + + throw new Error( + `未在文稿版本状态中找到 run_id=${expectedRunId},最后状态: ${JSON.stringify(latest)}`, + ); +} + +function verifyRunModelSelection(metadata, expectedProvider, expectedModel) { + const requestedProvider = pickMetadataString(metadata, [ + "requested_provider", + "provider_override", + "provider_id", + "provider", + ]); + const requestedModel = pickMetadataString(metadata, [ + "requested_model", + "model_override", + "model_name", + "model", + ]); + const resolvedProvider = pickMetadataString(metadata, [ + "resolved_provider", + "runtime_provider", + "provider_name", + ]); + const resolvedModel = pickMetadataString(metadata, [ + "resolved_model", + "runtime_model", + ]); + + if (expectedProvider) { + assert( + requestedProvider === expectedProvider, + `Provider 不匹配: expected=${expectedProvider}, actual_requested=${requestedProvider || ""}, actual_resolved=${resolvedProvider || ""}`, + ); + } + + if (expectedModel) { + assert( + requestedModel === expectedModel, + `模型不匹配: expected=${expectedModel}, actual_requested=${requestedModel || ""}, actual_resolved=${resolvedModel || ""}`, + ); + } + + return { + requestedProvider, + requestedModel, + resolvedProvider, + resolvedModel, + }; +} + +async function main() { + const args = parseArgs(process.argv); + if (args.help) { + printUsage(); + return; + } + + if (!args.sessionId) { + printUsage(); + throw new Error("缺少必填参数 --session-id"); + } + + console.log(`[Smoke] 开始校验 session: ${args.sessionId}`); + + await invoke("get_server_status"); + console.log("[Smoke] Dev Bridge 可用"); + + const runState = await waitTerminalState( + args.sessionId, + args.timeoutMs, + args.intervalMs, + ); + const terminal = runState.latest_terminal; + assert(terminal, "未获取到 latest_terminal"); + console.log( + `[Smoke] 终态: status=${terminal.status}, run_id=${terminal.run_id}, gate=${terminal.gate_key}`, + ); + + const runDetail = await invoke("execution_run_get", { + runId: terminal.run_id, + }); + assert(runDetail, "execution_run_get 返回为空"); + + const metadata = parseRunMetadata(runDetail.metadata); + assert(metadata, "运行 metadata 为空或不可解析"); + assert( + metadata.workflow === "social_content_pipeline_v1", + `workflow 不匹配: ${metadata.workflow}`, + ); + + const modelSelection = verifyRunModelSelection( + metadata, + args.expectedProvider, + args.expectedModel, + ); + console.log( + `[Smoke] 模型轨迹: requested=${modelSelection.requestedProvider || ""} / ${modelSelection.requestedModel || ""}, resolved=${modelSelection.resolvedProvider || ""} / ${modelSelection.resolvedModel || ""}`, + ); + + const stages = Array.isArray(metadata.stages) ? metadata.stages : []; + assert(stages.length >= 3, `stages 不完整: ${JSON.stringify(stages)}`); + assert(stages.includes("topic_select"), "stages 缺少 topic_select"); + assert(stages.includes("write_mode"), "stages 缺少 write_mode"); + assert(stages.includes("publish_confirm"), "stages 缺少 publish_confirm"); + + const artifactPaths = pickArtifactPaths(metadata); + assert( + artifactPaths.length >= 3, + `artifact_paths 不完整: ${JSON.stringify(artifactPaths)}`, + ); + console.log("[Smoke] 产物路径:", artifactPaths.join(", ")); + + const artifactSummary = await verifySessionArtifacts(args.sessionId, artifactPaths); + console.log( + `[Smoke] 会话产物校验通过: ${artifactSummary.articlePath}, ${artifactSummary.coverPath}, ${artifactSummary.publishPackPath}`, + ); + + if (args.contentId) { + const contentVersion = await verifyContentVersionState( + args.contentId, + terminal.run_id, + args.timeoutMs, + args.intervalMs, + ); + console.log( + `[Smoke] 文稿版本校验通过: current=${contentVersion.currentVersionId}, status=${contentVersion.matchedStatus || "unknown"}`, + ); + } else { + console.log("[Smoke] 跳过文稿版本校验(未提供 --content-id)"); + } + + console.log("[Smoke] ✅ 主题工作台社媒链路校验通过"); +} + +main().catch((error) => { + console.error("[Smoke] ❌ 校验失败:", error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index be1389d0d..92eba144e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6952,7 +6952,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.80.0" +version = "0.81.0" dependencies = [ "anyhow", "arboard", @@ -7054,7 +7054,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.80.0" +version = "0.81.0" dependencies = [ "aster-core", "async-trait", @@ -7079,7 +7079,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.80.0" +version = "0.81.0" dependencies = [ "async-trait", "parking_lot", @@ -7095,7 +7095,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.80.0" +version = "0.81.0" dependencies = [ "aster-models", "async-trait", @@ -7135,7 +7135,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.80.0" +version = "0.81.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7170,7 +7170,7 @@ dependencies = [ [[package]] name = "proxycast-gateway" -version = "0.80.0" +version = "0.81.0" dependencies = [ "axum 0.7.9", "chrono", @@ -7191,7 +7191,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.80.0" +version = "0.81.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7211,7 +7211,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.80.0" +version = "0.81.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7243,7 +7243,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.80.0" +version = "0.81.0" dependencies = [ "async-trait", "parking_lot", @@ -7262,7 +7262,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.80.0" +version = "0.81.0" dependencies = [ "anyhow", "async-stream", @@ -7316,7 +7316,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.80.0" +version = "0.81.0" dependencies = [ "aster-core", "async-stream", @@ -7361,7 +7361,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.80.0" +version = "0.81.0" dependencies = [ "axum 0.7.9", "futures", @@ -7376,7 +7376,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.80.0" +version = "0.81.0" dependencies = [ "anyhow", "aster-core", @@ -7417,7 +7417,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.80.0" +version = "0.81.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7433,7 +7433,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.80.0" +version = "0.81.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7460,7 +7460,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.80.0" +version = "0.81.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 41d89c5ae..a85a1e06d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.80.0" +version = "0.81.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -191,7 +191,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.80.0" +version = "0.81.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 21a2a3b6d..9ed9ed840 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -570,6 +570,9 @@ pub struct ContentCreatorConfig { /// 启用的主题列表 #[serde(default = "default_enabled_themes")] pub enabled_themes: Vec, + /// 全局媒体生成默认设置 + #[serde(default)] + pub media_defaults: MediaGenerationDefaultsConfig, } fn default_enabled_themes() -> Vec { @@ -587,10 +590,47 @@ impl Default for ContentCreatorConfig { fn default() -> Self { Self { enabled_themes: default_enabled_themes(), + media_defaults: MediaGenerationDefaultsConfig::default(), } } } +fn default_media_generation_allow_fallback() -> bool { + true +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaGenerationPreferenceConfig { + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "preferred_provider_id" + )] + pub preferred_provider_id: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "preferred_model_id" + )] + pub preferred_model_id: Option, + #[serde( + default = "default_media_generation_allow_fallback", + alias = "allow_fallback" + )] + pub allow_fallback: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct MediaGenerationDefaultsConfig { + #[serde(default)] + pub image: MediaGenerationPreferenceConfig, + #[serde(default)] + pub video: MediaGenerationPreferenceConfig, + #[serde(default)] + pub voice: MediaGenerationPreferenceConfig, +} + // ============ 导航栏配置类型 ============ /// 导航栏模块配置 diff --git a/src-tauri/crates/core/src/database/dao/agent_run.rs b/src-tauri/crates/core/src/database/dao/agent_run.rs index 96cb2f7a4..0e49691ab 100644 --- a/src-tauri/crates/core/src/database/dao/agent_run.rs +++ b/src-tauri/crates/core/src/database/dao/agent_run.rs @@ -206,6 +206,44 @@ impl AgentRunDao { iter.collect() } + + pub fn list_runs_by_session( + conn: &Connection, + session_id: &str, + limit: usize, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, source, source_ref, session_id, status, started_at, finished_at, duration_ms, + error_code, error_message, metadata, created_at, updated_at + FROM agent_runs + WHERE session_id = ?1 + ORDER BY started_at DESC + LIMIT ?2", + )?; + + let iter = stmt.query_map(params![session_id, limit as i64], |row| { + let status_raw: String = row.get(4)?; + let status = + AgentRunStatus::try_from(status_raw.as_str()).unwrap_or(AgentRunStatus::Error); + Ok(AgentRun { + id: row.get(0)?, + source: row.get(1)?, + source_ref: row.get(2)?, + session_id: row.get(3)?, + status, + started_at: row.get(5)?, + finished_at: row.get(6)?, + duration_ms: row.get(7)?, + error_code: row.get(8)?, + error_message: row.get(9)?, + metadata: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + }) + })?; + + iter.collect() + } } #[cfg(test)] @@ -292,4 +330,36 @@ mod tests { assert_eq!(fetched.status, AgentRunStatus::Success); assert_eq!(fetched.duration_ms, Some(100)); } + + #[test] + fn list_runs_by_session_should_filter_and_sort() { + let conn = setup_conn(); + + let mut run_1 = sample_run("run-a-1", AgentRunStatus::Success); + run_1.session_id = Some("session-a".to_string()); + run_1.started_at = "2026-03-06T10:00:00Z".to_string(); + run_1.created_at = run_1.started_at.clone(); + run_1.updated_at = run_1.started_at.clone(); + AgentRunDao::create_run(&conn, &run_1).expect("写入 run-a-1 失败"); + + let mut run_2 = sample_run("run-b-1", AgentRunStatus::Running); + run_2.session_id = Some("session-b".to_string()); + run_2.started_at = "2026-03-06T11:00:00Z".to_string(); + run_2.created_at = run_2.started_at.clone(); + run_2.updated_at = run_2.started_at.clone(); + AgentRunDao::create_run(&conn, &run_2).expect("写入 run-b-1 失败"); + + let mut run_3 = sample_run("run-a-2", AgentRunStatus::Error); + run_3.session_id = Some("session-a".to_string()); + run_3.started_at = "2026-03-06T12:00:00Z".to_string(); + run_3.created_at = run_3.started_at.clone(); + run_3.updated_at = run_3.started_at.clone(); + AgentRunDao::create_run(&conn, &run_3).expect("写入 run-a-2 失败"); + + let runs = AgentRunDao::list_runs_by_session(&conn, "session-a", 10) + .expect("按 session 查询执行记录失败"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].id, "run-a-2"); + assert_eq!(runs[1].id, "run-a-1"); + } } diff --git a/src-tauri/crates/core/src/session_files/storage.rs b/src-tauri/crates/core/src/session_files/storage.rs index 5077ed031..03475ec17 100644 --- a/src-tauri/crates/core/src/session_files/storage.rs +++ b/src-tauri/crates/core/src/session_files/storage.rs @@ -223,6 +223,29 @@ impl SessionFileStorage { fs::read_to_string(&file_path).map_err(|e| format!("读取文件失败: {e}")) } + /// 解析会话文件的绝对路径 + pub fn resolve_file_path(&self, session_id: &str, file_name: &str) -> Result { + let files_dir = self.get_files_dir(session_id); + let file_path = files_dir.join(file_name); + + if !file_path.exists() { + return Err("文件不存在".to_string()); + } + + let canonical_file_path = file_path + .canonicalize() + .map_err(|e| format!("解析文件路径失败: {e}"))?; + let canonical_files_dir = files_dir + .canonicalize() + .map_err(|e| format!("解析会话目录失败: {e}"))?; + + if !canonical_file_path.starts_with(&canonical_files_dir) { + return Err("非法文件路径".to_string()); + } + + Ok(canonical_file_path.to_string_lossy().to_string()) + } + /// 删除会话文件 pub fn delete_file(&self, session_id: &str, file_name: &str) -> Result<(), String> { let file_path = self.get_files_dir(session_id).join(file_name); @@ -426,4 +449,18 @@ mod tests { storage.delete_session("test-session-4").unwrap(); assert!(!storage.session_exists("test-session-4")); } + + #[test] + fn test_resolve_file_path() { + let (storage, _temp) = create_test_storage(); + storage.create_session("test-session-5").unwrap(); + storage + .save_file("test-session-5", "demo.md", "content") + .unwrap(); + + let resolved = storage + .resolve_file_path("test-session-5", "demo.md") + .unwrap(); + assert!(resolved.ends_with("/test-session-5/files/demo.md")); + } } diff --git a/src-tauri/crates/core/src/workspace/types.rs b/src-tauri/crates/core/src/workspace/types.rs index ca3fcd5a9..ff18f78d8 100644 --- a/src-tauri/crates/core/src/workspace/types.rs +++ b/src-tauri/crates/core/src/workspace/types.rs @@ -91,18 +91,119 @@ impl WorkspaceType { } } +fn default_image_generation_allow_fallback() -> bool { + true +} + +/// 图片生成偏好设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceImageGenerationSettings { + /// 默认图片 Provider ID + #[serde( + skip_serializing_if = "Option::is_none", + alias = "preferred_provider_id" + )] + pub preferred_provider_id: Option, + /// 默认图片模型 ID + #[serde(skip_serializing_if = "Option::is_none", alias = "preferred_model_id")] + pub preferred_model_id: Option, + /// 默认图片 Provider 不可用时是否允许回退自动选择 + #[serde( + default = "default_image_generation_allow_fallback", + alias = "allow_fallback" + )] + pub allow_fallback: bool, +} + +/// 视频生成偏好设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceVideoGenerationSettings { + #[serde( + skip_serializing_if = "Option::is_none", + alias = "preferred_provider_id" + )] + pub preferred_provider_id: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "preferred_model_id")] + pub preferred_model_id: Option, + #[serde( + default = "default_image_generation_allow_fallback", + alias = "allow_fallback" + )] + pub allow_fallback: bool, +} + +impl Default for WorkspaceVideoGenerationSettings { + fn default() -> Self { + Self { + preferred_provider_id: None, + preferred_model_id: None, + allow_fallback: true, + } + } +} + +/// 语音生成偏好设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceVoiceGenerationSettings { + #[serde( + skip_serializing_if = "Option::is_none", + alias = "preferred_provider_id" + )] + pub preferred_provider_id: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "preferred_model_id")] + pub preferred_model_id: Option, + #[serde( + default = "default_image_generation_allow_fallback", + alias = "allow_fallback" + )] + pub allow_fallback: bool, +} + +impl Default for WorkspaceVoiceGenerationSettings { + fn default() -> Self { + Self { + preferred_provider_id: None, + preferred_model_id: None, + allow_fallback: true, + } + } +} + +impl Default for WorkspaceImageGenerationSettings { + fn default() -> Self { + Self { + preferred_provider_id: None, + preferred_model_id: None, + allow_fallback: true, + } + } +} + /// Workspace 级别设置 #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct WorkspaceSettings { /// Workspace 级 MCP 配置 - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none", alias = "mcp_config")] pub mcp_config: Option, /// 默认 provider - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none", alias = "default_provider")] pub default_provider: Option, /// 自动压缩 context - #[serde(default)] + #[serde(default, alias = "auto_compact")] pub auto_compact: bool, + /// 图片生成偏好 + #[serde(skip_serializing_if = "Option::is_none", alias = "image_generation")] + pub image_generation: Option, + /// 视频生成偏好 + #[serde(skip_serializing_if = "Option::is_none", alias = "video_generation")] + pub video_generation: Option, + /// 语音生成偏好 + #[serde(skip_serializing_if = "Option::is_none", alias = "voice_generation")] + pub voice_generation: Option, } /// 项目统计信息 @@ -330,4 +431,122 @@ mod tests { let debug_str = format!("{wt:?}"); assert_eq!(debug_str, "SocialMedia"); } + + #[test] + fn test_workspace_settings_accepts_legacy_snake_case() { + let settings: WorkspaceSettings = serde_json::from_str( + r#"{ + "default_provider": "openai", + "auto_compact": true, + "image_generation": { + "preferred_provider_id": "new-api", + "preferred_model_id": "gpt-image-1", + "allow_fallback": false + }, + "video_generation": { + "preferred_provider_id": "doubao-video", + "preferred_model_id": "seedance-1-5-pro-251215", + "allow_fallback": true + }, + "voice_generation": { + "preferred_provider_id": "openai-tts", + "preferred_model_id": "gpt-4o-mini-tts", + "allow_fallback": false + } + }"#, + ) + .unwrap(); + + assert_eq!(settings.default_provider.as_deref(), Some("openai")); + assert!(settings.auto_compact); + let image_generation = settings.image_generation.expect("应解析图片配置"); + assert_eq!( + image_generation.preferred_provider_id.as_deref(), + Some("new-api") + ); + assert_eq!( + image_generation.preferred_model_id.as_deref(), + Some("gpt-image-1") + ); + assert!(!image_generation.allow_fallback); + let video_generation = settings.video_generation.expect("应解析视频配置"); + assert_eq!( + video_generation.preferred_provider_id.as_deref(), + Some("doubao-video") + ); + assert_eq!( + video_generation.preferred_model_id.as_deref(), + Some("seedance-1-5-pro-251215") + ); + assert!(video_generation.allow_fallback); + let voice_generation = settings.voice_generation.expect("应解析语音配置"); + assert_eq!( + voice_generation.preferred_provider_id.as_deref(), + Some("openai-tts") + ); + assert_eq!( + voice_generation.preferred_model_id.as_deref(), + Some("gpt-4o-mini-tts") + ); + assert!(!voice_generation.allow_fallback); + } + + #[test] + fn test_workspace_settings_serializes_to_camel_case() { + let settings = WorkspaceSettings { + image_generation: Some(WorkspaceImageGenerationSettings { + preferred_provider_id: Some("new-api".to_string()), + preferred_model_id: Some("gpt-image-1".to_string()), + allow_fallback: false, + }), + video_generation: Some(WorkspaceVideoGenerationSettings { + preferred_provider_id: Some("doubao-video".to_string()), + preferred_model_id: Some("seedance-1-5-pro-251215".to_string()), + allow_fallback: true, + }), + voice_generation: Some(WorkspaceVoiceGenerationSettings { + preferred_provider_id: Some("openai-tts".to_string()), + preferred_model_id: Some("gpt-4o-mini-tts".to_string()), + allow_fallback: false, + }), + ..WorkspaceSettings::default() + }; + + let value = serde_json::to_value(&settings).unwrap(); + assert_eq!( + value + .get("imageGeneration") + .and_then(|item| item.get("preferredProviderId")) + .and_then(|item| item.as_str()), + Some("new-api") + ); + assert_eq!( + value + .get("imageGeneration") + .and_then(|item| item.get("preferredModelId")) + .and_then(|item| item.as_str()), + Some("gpt-image-1") + ); + assert_eq!( + value + .get("imageGeneration") + .and_then(|item| item.get("allowFallback")) + .and_then(|item| item.as_bool()), + Some(false) + ); + assert_eq!( + value + .get("videoGeneration") + .and_then(|item| item.get("preferredProviderId")) + .and_then(|item| item.as_str()), + Some("doubao-video") + ); + assert_eq!( + value + .get("voiceGeneration") + .and_then(|item| item.get("preferredModelId")) + .and_then(|item| item.as_str()), + Some("gpt-4o-mini-tts") + ); + } } diff --git a/src-tauri/crates/providers/src/providers/openai_custom.rs b/src-tauri/crates/providers/src/providers/openai_custom.rs index 079f4d4ac..4cf1da0e8 100644 --- a/src-tauri/crates/providers/src/providers/openai_custom.rs +++ b/src-tauri/crates/providers/src/providers/openai_custom.rs @@ -256,9 +256,11 @@ impl OpenAICustomProvider { } } - fn base_url_parent(&self) -> Option { - let base = self.get_base_url(); + fn parent_base_url(base: &str) -> Option { let base = base.trim(); + if base.is_empty() { + return None; + } let mut url = Url::parse(base) .or_else(|_| Url::parse(&format!("http://{base}"))) @@ -288,6 +290,11 @@ impl OpenAICustomProvider { Some(url.to_string().trim_end_matches('/').to_string()) } + fn base_url_parent(&self) -> Option { + let base = self.get_base_url(); + Self::parent_base_url(&base) + } + fn build_urls_with_fallbacks(&self, endpoint: &str) -> Vec { let mut urls: Vec = Vec::new(); @@ -300,8 +307,13 @@ impl OpenAICustomProvider { } } - if let Some(parent_base) = self.base_url_parent() { - let u = Self::build_url_from_base(&parent_base, endpoint); + let mut parent_base = self.base_url_parent(); + for _ in 0..6 { + let Some(current_parent) = parent_base else { + break; + }; + + let u = Self::build_url_from_base(¤t_parent, endpoint); if !urls.iter().any(|x| x == &u) { urls.push(u.clone()); } @@ -312,6 +324,8 @@ impl OpenAICustomProvider { urls.push(u2); } } + + parent_base = Self::parent_base_url(¤t_parent); } urls @@ -766,6 +780,19 @@ mod tests { assert!(description.contains("[InputExamples]")); } + #[test] + fn test_build_urls_with_fallbacks_supports_nested_proxy_path() { + let provider = OpenAICustomProvider::with_config( + "sk-test".to_string(), + Some("http://127.0.0.1:3030/openai/v1".to_string()), + ); + let urls = provider.build_urls_with_fallbacks("chat/completions"); + + assert!(urls.contains(&"http://127.0.0.1:3030/openai/v1/chat/completions".to_string())); + assert!(urls.contains(&"http://127.0.0.1:3030/openai/chat/completions".to_string())); + assert!(urls.contains(&"http://127.0.0.1:3030/v1/chat/completions".to_string())); + } + #[tokio::test] async fn test_openai_compatible_non_stream_and_stream_both_normalized() { if !OpenAICustomProvider::tool_calling_v2_enabled() { diff --git a/src-tauri/crates/services/src/live_sync.rs b/src-tauri/crates/services/src/live_sync.rs index 74511a6ee..97f1ecb19 100644 --- a/src-tauri/crates/services/src/live_sync.rs +++ b/src-tauri/crates/services/src/live_sync.rs @@ -87,7 +87,7 @@ fn should_create_backup() -> bool { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +#[allow(dead_code)] enum ShellConfigSyntax { Posix, PowerShell, diff --git a/src-tauri/crates/services/src/machine_id_service.rs b/src-tauri/crates/services/src/machine_id_service.rs index d3719ad1b..85ec7c3a1 100644 --- a/src-tauri/crates/services/src/machine_id_service.rs +++ b/src-tauri/crates/services/src/machine_id_service.rs @@ -9,10 +9,6 @@ use std::process::Command; use tracing; use uuid::Uuid; -#[cfg(target_os = "windows")] -use std::ptr; -#[cfg(target_os = "windows")] -use winapi::um::winnt::KEY_READ; #[cfg(target_os = "windows")] use winreg::{enums::*, RegKey}; diff --git a/src-tauri/crates/services/src/provider_pool_service.rs b/src-tauri/crates/services/src/provider_pool_service.rs index ec5648218..2004e2010 100644 --- a/src-tauri/crates/services/src/provider_pool_service.rs +++ b/src-tauri/crates/services/src/provider_pool_service.rs @@ -40,7 +40,7 @@ impl ProviderCredentialClientCompat for ProviderCredential { true } } -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicUsize; use std::time::Duration; @@ -1288,23 +1288,120 @@ impl ProviderPoolService { // OpenAI API 健康检查 // 与 OpenAI Provider 保持一致的 URL 处理逻辑 + fn is_version_path_segment(segment: &str) -> bool { + segment.starts_with('v') + && segment.len() >= 2 + && segment[1..].chars().all(|c| c.is_ascii_digit()) + } + + fn build_openai_url_from_base(base_url: &str, endpoint: &str) -> String { + let base = base_url.trim_end_matches('/'); + let has_version = base + .rsplit('/') + .next() + .map(Self::is_version_path_segment) + .unwrap_or(false); + + if has_version { + format!("{base}/{endpoint}") + } else { + format!("{base}/v1/{endpoint}") + } + } + + fn parent_base_url(base_url: &str) -> Option { + let base = base_url.trim(); + if base.is_empty() { + return None; + } + + let mut url = reqwest::Url::parse(base) + .or_else(|_| reqwest::Url::parse(&format!("http://{base}"))) + .ok()?; + + let path = url.path().trim_end_matches('/'); + if path.is_empty() || path == "/" { + return None; + } + + let mut segments: Vec<&str> = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + if segments.is_empty() { + return None; + } + segments.pop(); + + let new_path = if segments.is_empty() { + "/".to_string() + } else { + format!("/{}", segments.join("/")) + }; + + url.set_path(&new_path); + url.set_query(None); + url.set_fragment(None); + + Some(url.to_string().trim_end_matches('/').to_string()) + } + + fn push_openai_url_candidates(urls: &mut Vec, base_url: &str, endpoint: &str) { + if base_url.trim().is_empty() { + return; + } + + let primary = Self::build_openai_url_from_base(base_url, endpoint); + if !urls.iter().any(|url| url == &primary) { + urls.push(primary.clone()); + } + + if primary.contains("/v1/") { + let no_v1 = primary.replacen("/v1/", "/", 1); + if !urls.iter().any(|url| url == &no_v1) { + urls.push(no_v1); + } + } + } + + fn build_openai_health_check_urls(base_url: Option<&str>) -> Vec { + let raw_base = base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("https://api.openai.com"); + let normalized_base = raw_base.trim_end_matches('/').to_string(); + + let mut urls = Vec::new(); + let mut visited = HashSet::new(); + visited.insert(normalized_base.clone()); + + Self::push_openai_url_candidates(&mut urls, &normalized_base, "chat/completions"); + + let mut current = normalized_base; + for _ in 0..6 { + let Some(parent) = Self::parent_base_url(¤t) else { + break; + }; + if !visited.insert(parent.clone()) { + break; + } + Self::push_openai_url_candidates(&mut urls, &parent, "chat/completions"); + current = parent; + } + + if urls.is_empty() { + urls.push("https://api.openai.com/v1/chat/completions".to_string()); + } + urls + } + async fn check_openai_health( &self, api_key: &str, base_url: Option<&str>, model: &str, ) -> Result<(), String> { - // base_url 应该不带 /v1,在这里拼接 - // 但为了兼容用户可能输入带 /v1 的情况,这里做智能处理 - let base = base_url.unwrap_or("https://api.openai.com"); - let base = base.trim_end_matches('/'); - - // 如果用户输入了带 /v1 的 URL,直接使用;否则拼接 /v1 - let url = if base.ends_with("/v1") { - format!("{base}/chat/completions") - } else { - format!("{base}/v1/chat/completions") - }; + let urls = Self::build_openai_health_check_urls(base_url); let request_body = serde_json::json!({ "model": model, @@ -1312,29 +1409,66 @@ impl ProviderPoolService { "max_tokens": 10 }); - tracing::debug!("[HEALTH_CHECK] OpenAI API URL: {}, model: {}", url, model); + let mut last_error: Option = None; - let response = self - .client - .post(&url) - .bearer_auth(api_key) - .json(&request_body) - .timeout(self.health_check_timeout) - .send() - .await - .map_err(|e| format!("请求失败: {e}"))?; + for (index, url) in urls.iter().enumerate() { + tracing::debug!("[HEALTH_CHECK] OpenAI API URL: {}, model: {}", url, model); + + let response = match self + .client + .post(url) + .bearer_auth(api_key) + .json(&request_body) + .timeout(self.health_check_timeout) + .send() + .await + { + Ok(response) => response, + Err(error) => { + let message = format!("请求失败: {error}"); + last_error = Some(message.clone()); + if index + 1 < urls.len() { + tracing::warn!( + "[HEALTH_CHECK] OpenAI API URL {} 请求失败,继续尝试后续候选: {}", + url, + message + ); + continue; + } + return Err(message); + } + }; + + if response.status().is_success() { + return Ok(()); + } - if response.status().is_success() { - Ok(()) - } else { let status = response.status(); let body = response.text().await.unwrap_or_default(); - Err(format!( + let message = format!( "HTTP {} - {}", status, body.chars().take(200).collect::() - )) + ); + last_error = Some(message.clone()); + + let can_retry_next_url = matches!( + status, + reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED + ); + if can_retry_next_url && index + 1 < urls.len() { + tracing::warn!( + "[HEALTH_CHECK] OpenAI API URL {} 返回 {},尝试下一个候选 URL", + url, + status + ); + continue; + } + + return Err(message); } + + Err(last_error.unwrap_or_else(|| "OpenAI 健康检查失败".to_string())) } // Claude API 健康检查 @@ -2183,4 +2317,21 @@ mod tests { PoolProviderType::OpenAI ); } + + #[test] + fn test_build_openai_health_check_urls_supports_nested_base_path() { + let urls = ProviderPoolService::build_openai_health_check_urls(Some( + "http://127.0.0.1:3030/openai/v1", + )); + + assert!(urls.contains(&"http://127.0.0.1:3030/openai/v1/chat/completions".to_string())); + assert!(urls.contains(&"http://127.0.0.1:3030/openai/chat/completions".to_string())); + assert!(urls.contains(&"http://127.0.0.1:3030/v1/chat/completions".to_string())); + } + + #[test] + fn test_build_openai_health_check_urls_defaults_to_official_endpoint() { + let urls = ProviderPoolService::build_openai_health_check_urls(None); + assert_eq!(urls[0], "https://api.openai.com/v1/chat/completions"); + } } diff --git a/src-tauri/crates/terminal/src/connections/wsl_connection.rs b/src-tauri/crates/terminal/src/connections/wsl_connection.rs index 743434674..0c6c0cf67 100644 --- a/src-tauri/crates/terminal/src/connections/wsl_connection.rs +++ b/src-tauri/crates/terminal/src/connections/wsl_connection.rs @@ -626,7 +626,7 @@ impl WSLShellProc { input_rx: mpsc::Receiver, block_file: Option>, ) -> Result { - use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use portable_pty::{native_pty_system, PtySize}; tracing::info!( "[WSLShellProc] 创建 WSL 进程: block_id={}, distro={}, size={}x{}", diff --git a/src-tauri/crates/websocket/src/handlers/rpc_handler.rs b/src-tauri/crates/websocket/src/handlers/rpc_handler.rs index 0d89bd020..0d76d1c97 100644 --- a/src-tauri/crates/websocket/src/handlers/rpc_handler.rs +++ b/src-tauri/crates/websocket/src/handlers/rpc_handler.rs @@ -427,6 +427,8 @@ impl RpcHandler { .require_db() .await .map_err(|e| RpcError::internal_error(e.message))?; + AgentScheduler::init_tables(&db) + .map_err(|e| RpcError::internal_error(format!("init cron tables failed: {e}")))?; let conn = proxycast_core::database::lock_db(&db) .map_err(|e| RpcError::internal_error(format!("DB lock failed: {e}")))?; let raw_tasks = SchedulerDao::list_tasks( @@ -478,6 +480,8 @@ impl RpcHandler { .require_db() .await .map_err(|e| RpcError::internal_error(e.message))?; + AgentScheduler::init_tables(&db) + .map_err(|e| RpcError::internal_error(format!("init cron tables failed: {e}")))?; let execution_id = Uuid::new_v4().to_string(); let task = { let conn = proxycast_core::database::lock_db(&db) diff --git a/src-tauri/resources/default-skills/broadcast_generate/SKILL.md b/src-tauri/resources/default-skills/broadcast_generate/SKILL.md new file mode 100644 index 000000000..3a3a99e86 --- /dev/null +++ b/src-tauri/resources/default-skills/broadcast_generate/SKILL.md @@ -0,0 +1,32 @@ +--- +name: broadcast_generate +description: 将文章整理为可转播客音频的源文本(下游负责真实音频合成)。 +allowed-tools: proxycast_create_broadcast_generation_task +argument-hint: 输入原文、目标听众、语气、预计时长、重点段落。 +when-to-use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的播客内容整理助手。 + +## 工作目标 + +将用户提供的图文内容整理成“适合下游音频转换”的文稿包,保持事实准确、结构清晰、可听性强。 + +## 执行规则 + +- 保留原文核心观点与证据,不随意新增事实。 +- 清理不利于朗读的内容(超长句、无意义链接堆叠、重复段)。 +- 输出的是“可播报文本材料”,不是完整主持人口播脚本。 +- 必须调用 `proxycast_create_broadcast_generation_task` 创建任务。 +- `payload` 中至少包含:`title`、`audience`、`tone`、`durationHintMinutes`、`content`。 + +## 输出格式(固定) + +仅输出任务提交摘要(不要再写 ``): + +- 任务类型:broadcast_generate +- 任务 ID:{task_id} +- 任务文件:{path} +- 状态:pending_submit diff --git a/src-tauri/resources/default-skills/cover_generate/SKILL.md b/src-tauri/resources/default-skills/cover_generate/SKILL.md new file mode 100644 index 000000000..f6a7c559d --- /dev/null +++ b/src-tauri/resources/default-skills/cover_generate/SKILL.md @@ -0,0 +1,33 @@ +--- +name: cover_generate +description: 为文章或视频生成平台封面图,并写回主稿(封面场景优先使用本技能)。 +allowed-tools: social_generate_cover_image, proxycast_create_cover_generation_task +argument-hint: 输入平台、标题、受众、视觉风格、尺寸要求。 +when-to-use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的封面生成助手。 + +## 工作目标 + +围绕当前主稿主题生成一张“可发布”的封面图,并给出可追溯的生成信息。 + +## 执行规则 + +- 封面任务优先,不要退化成普通插图。 +- 根据平台特性控制视觉:主体清晰、构图简洁、避免密集小字。 +- 默认尺寸 `1024x1024`,用户指定时优先按用户要求。 +- 使用 `social_generate_cover_image` 生成封面。 +- 生成后必须调用 `proxycast_create_cover_generation_task` 创建任务。 +- 工具失败时不能中断:保留占位、给出重试建议并提交失败任务记录。 + +## 输出格式(固定) + +仅输出任务提交摘要(不要再写 ``): + +- 任务类型:cover_generate +- 任务 ID:{task_id} +- 任务文件:{path} +- 状态:{pending_submit} diff --git a/src-tauri/resources/default-skills/image_generate/SKILL.md b/src-tauri/resources/default-skills/image_generate/SKILL.md new file mode 100644 index 000000000..d5427691e --- /dev/null +++ b/src-tauri/resources/default-skills/image_generate/SKILL.md @@ -0,0 +1,32 @@ +--- +name: image_generate +description: 根据文本描述生成配图素材(非封面场景)。 +allowed-tools: proxycast_create_image_generation_task +argument-hint: 输入主题、画面主体、风格、构图、数量、尺寸。 +when-to-use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的通用配图助手。 + +## 工作目标 + +将用户需求转成高质量配图提示词与任务参数,确保生成结果可直接用于正文配图。 + +## 执行规则 + +- 先判断是否属于封面需求;封面需求请转 `cover_generate`。 +- 提示词必须包含主体、场景、风格,不要空泛。 +- 若用户给了参考素材,需体现在参数中。 +- 必须调用 `proxycast_create_image_generation_task` 创建任务。 +- `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`。 + +## 输出格式(固定) + +仅输出任务提交摘要(不要再写 ``): + +- 任务类型:image_generate +- 任务 ID:{task_id} +- 任务文件:{path} +- 状态:pending_submit diff --git a/src-tauri/resources/default-skills/library/SKILL.md b/src-tauri/resources/default-skills/library/SKILL.md new file mode 100644 index 000000000..c6da6b091 --- /dev/null +++ b/src-tauri/resources/default-skills/library/SKILL.md @@ -0,0 +1,44 @@ +--- +name: library +description: 【外部资产库】读取项目参考资料(/project)或风格参考(/styles)。 +allowed-tools: list_directory, read_file +argument-hint: 输入要读取的目录、文件路径、目标主题与提取重点。 +when-to-use: 需要读取项目内参考资料,或提炼风格样例时使用。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的资料库读取助手。 + +## 工作目标 + +从可访问的资料目录中读取内容,提炼与当前任务最相关的信息,输出结构化摘要供后续写作或改写使用。 + +## 执行规则 + +- 常规任务优先读取 `/project` 资料;仅在用户明确要求时读取 `/styles`。 +- 避免全量扫库,先列目录再按需读取目标文件。 +- 提取结论时要标注来源文件路径,便于追溯。 +- 不编造不存在的文件或内容。 + +## 输出格式(固定) + + +# 资料提炼结果 + +## 读取范围 +- 目录:{已读取目录} +- 文件:{已读取文件路径列表} + +## 核心结论 +- {结论 1} +- {结论 2} +- {结论 3} + +## 风格提示(可选) +- {仅在读取 /styles 时输出} + +## 来源 +- {文件路径 A} +- {文件路径 B} + diff --git a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md new file mode 100644 index 000000000..20940e593 --- /dev/null +++ b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md @@ -0,0 +1,32 @@ +--- +name: modal_resource_search +description: 提交资源检索任务(图片、背景音乐、音效等),供前端资源面板消费。 +allowed-tools: proxycast_create_modal_resource_search_task +argument-hint: 输入资源类型、关键词、风格、用途、数量与限制条件。 +when-to-use: 用户需要为当前内容补充外部素材资源时使用。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的资源检索编排助手。 + +## 工作目标 + +把素材需求结构化为“可执行检索任务”,并输出简明候选清单,方便用户快速确认。 + +## 执行规则 + +- 先明确资源类型(图片/BGM/音效)和使用场景。 +- 检索关键词控制在 1-3 个核心词,避免长句。 +- 优先给出高相关候选,不要堆无关结果。 +- 必须调用 `proxycast_create_modal_resource_search_task` 创建任务。 +- `payload` 中至少包含:`resourceType`、`query`、`usage`、`count`。 + +## 输出格式(固定) + +仅输出任务提交摘要(不要再写 ``): + +- 任务类型:modal_resource_search +- 任务 ID:{task_id} +- 任务文件:{path} +- 状态:pending_submit diff --git a/src-tauri/resources/default-skills/research/SKILL.md b/src-tauri/resources/default-skills/research/SKILL.md new file mode 100644 index 000000000..7b1798d5f --- /dev/null +++ b/src-tauri/resources/default-skills/research/SKILL.md @@ -0,0 +1,49 @@ +--- +name: research +description: 联网信息检索与趋势调研(优先产出可引用结论,而非原始片段堆砌)。 +allowed-tools: search_query +argument-hint: 输入调研主题、目标平台、时间范围、输出深度与关注维度。 +when-to-use: 用户需要事实核验、最新信息补充、行业/平台趋势调研时使用。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的调研助手。 + +## 工作目标 + +通过可用检索能力产出“结论 + 证据来源 + 可执行建议”的调研结果。 + +## 执行规则 + +- 优先使用 1-3 个核心关键词,不要用冗长问句直接检索。 +- 如需“最新”信息,检索词必须包含年份(当前年份:2026)。 +- 检索后先去噪再归纳,不直接粘贴零散片段。 +- 事实不确定时要显式标注“待确认”,不要伪造结论。 +- 输出最多 3 条关键来源,强调可追溯。 + +## 输出格式(固定) + + +# 调研结果 + +## 研究问题 +{问题描述} + +## 核心结论 +- {结论 1} +- {结论 2} +- {结论 3} + +## 证据与来源 +- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要} +- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要} + +## 建议动作 +- {建议 1} +- {建议 2} + +## 备注 +- 检索关键词:{关键词列表} +- 不确定项:{如有则列出} + diff --git a/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md b/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md new file mode 100644 index 000000000..8e132e840 --- /dev/null +++ b/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md @@ -0,0 +1,104 @@ +--- +name: social_post_with_cover +description: 生成可直接发布的社媒成稿(默认公众号风格)并自动生成 1 张头图,最终以 write_file 落盘。 +allowed-tools: social_generate_cover_image, search_query +argument-hint: 输入主题、平台(如公众号/小红书)、目标受众、语气、字数、转化目标和已知素材。 +when-to-use: 用户需要“社媒文章 + 封面图”一体化输出,且希望直接复制发布。 +version: 1.2.0 +execution-mode: prompt +--- + +你是资深社媒内容策划与文案编辑,请根据用户输入生成高质量社媒文章,并调用工具生成封面图。 + +## 工作目标 + +1. 先输出完整社媒文章(默认以“可直接发布到微信公众号”的长文标准执行)。 +2. 调用 `social_generate_cover_image` 生成 1 张封面图(头图)。 +3. 将文章与图片结果整合为一份可直接发布的 Markdown 主稿。 +4. 最终必须落盘为一个 `social-posts/*.md` 文件(通过 `` 标签输出)。 + +## 执行规则 + +### A. 上下文与检索规则(必须遵守) + +- 优先吸收并使用用户输入中的上下文(例如 `[生效上下文]`、`[历史内容]`、素材、链接、摘要)。 +- 如果已有上下文足够,直接基于上下文写作,不要忽略用户提供信息。 +- 如果上下文不足且工具可用,优先调用 `search_query` 进行 2-4 次检索,再融合关键信息写作。 +- 检索信息必须“去噪整合”,不要原样堆砌搜索片段。 +- 未检索到可靠信息时,明确“基于现有上下文与通用经验”输出,不得编造具体来源。 + +### B. 文案生成规则(公众号优先) + +- 必须匹配用户指定的平台语气(如公众号、小红书、微博、LinkedIn 等)。 +- 未指定平台时,默认按“公众号可发布长文”执行(专业、清晰、有实用价值)。 +- 标题要具体,不要“空泛鸡汤式标题党”。 +- 结构清晰:标题、导语、正文分节、结尾 CTA。 +- 段落要短,适合移动端阅读;尽量给出可执行建议或案例。 +- 技术类内容允许出现少量代码示例,但不要大段无解释代码堆砌。 +- 严禁在正文中输出过程元数据或结构化字段(如 `article_path`、`cover_meta_path`、`execution_id`、JSON/YAML)。 + +### C. 封面图生成规则 + +- 使用文章主题与目标受众提炼成可视化提示词。 +- 封面图风格要求: + - 主体明确 + - 构图简洁 + - 适合社媒封面阅读 + - 不包含复杂小字 +- 默认尺寸使用 `1024x1024`(除非用户明确指定)。 +- 调用工具参数至少包含: + - `prompt` + - `size` + +### D. 失败降级规则(必须遵守) + +- 如果工具调用失败: + - 文章仍必须完整输出; + - 封面图位置使用占位文本; + - 提供简洁重试建议; + - 不要中断任务,不要让用户“先确认再继续”。 + +## 输出格式(固定) + +请严格按以下格式输出,且最终结果必须在一个 `` 块内: + +```markdown + +# {标题} + +![封面图]({图片URL或占位符}) + +## 导语 +{导语内容} + +## 正文 +{正文内容} + +## 结尾 +{结尾与行动号召} + +## 配图说明 +- 提示词:{用于生成封面图的 prompt} +- 尺寸:{size} +- 状态:{成功/失败} +- 备注:{失败时给出一句重试建议;成功时可留空} + +## 参考信息 +- 来源:{如有检索,写来源名称或站点}(日期:{YYYY-MM-DD}) +- 来源:{可选,最多 3 条} + +``` + +补充约束: +- 只输出一个主稿 ``,不要输出多个版本文件。 +- 不要在 `` 之外重复正文全文。 +- `` 内只能放“最终可发布主稿”,不要混入发布包 JSON 或过程日志。 + +## 质量检查清单 + +- 标题是否有传播性与主题相关性。 +- 正文是否贴合目标受众。 +- 是否明确吸收了用户给定上下文与约束。 +- 若使用检索,是否将结论转化为可读内容并附简要来源。 +- 封面图提示词是否与文案核心一致。 +- 输出是否可直接复制发布。 diff --git a/src-tauri/resources/default-skills/typesetting/SKILL.md b/src-tauri/resources/default-skills/typesetting/SKILL.md new file mode 100644 index 000000000..88432a8d0 --- /dev/null +++ b/src-tauri/resources/default-skills/typesetting/SKILL.md @@ -0,0 +1,33 @@ +--- +name: typesetting +description: 优化文稿排版与可读性,不改变原始事实与核心表达。 +allowed-tools: proxycast_create_typesetting_task +argument-hint: 输入目标平台、语气要求、段落长度偏好、标题层级规范。 +when-to-use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的排版优化助手。 + +## 工作目标 + +在不改变原意与事实的前提下,优化文稿结构、层级、段落节奏与视觉可读性。 + +## 执行规则 + +- 不新增未经用户确认的观点与事实。 +- 不改变原文立场,仅做结构化与可读性优化。 +- 控制段落长度,优先移动端阅读体验。 +- 标题层级清晰,列表格式统一。 +- 必须调用 `proxycast_create_typesetting_task` 创建任务。 +- `payload` 中至少包含:`targetPlatform`、`rules`、`content`。 + +## 输出格式(固定) + +仅输出任务提交摘要(不要再写 ``): + +- 任务类型:typesetting +- 任务 ID:{task_id} +- 任务文件:{path} +- 状态:pending_submit diff --git a/src-tauri/resources/default-skills/url_parse/SKILL.md b/src-tauri/resources/default-skills/url_parse/SKILL.md new file mode 100644 index 000000000..e73f73f32 --- /dev/null +++ b/src-tauri/resources/default-skills/url_parse/SKILL.md @@ -0,0 +1,32 @@ +--- +name: url_parse +description: 解析外部 URL 内容,并沉淀为可阅读的文本结果。 +allowed-tools: proxycast_create_url_parse_task +argument-hint: 输入 URL、抽取目标(摘要/要点/全文清洗)、输出格式要求。 +when-to-use: 用户提供链接并希望抽取正文、要点或可引用信息时使用。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的链接解析助手。 + +## 工作目标 + +围绕用户提供的 URL 产出“可阅读、可引用、可继续加工”的文本结果。 + +## 执行规则 + +- 先校验 URL 是否完整可读;不完整时先提示补全。 +- 若当前会话存在可用抓取工具,则优先工具抓取;否则明确降级为“基于用户提供内容整理”。 +- 提炼时区分“原文信息”与“你的归纳”,避免混淆。 +- 必须调用 `proxycast_create_url_parse_task` 创建任务。 +- `payload` 中至少包含:`url`、`summary`、`keyPoints`、`extractStatus`。 + +## 输出格式(固定) + +仅输出任务提交摘要(不要再写 ``): + +- 任务类型:url_parse +- 任务 ID:{task_id} +- 任务文件:{path} +- 状态:pending_submit diff --git a/src-tauri/resources/default-skills/video_generate/SKILL.md b/src-tauri/resources/default-skills/video_generate/SKILL.md new file mode 100644 index 000000000..e351b2bb8 --- /dev/null +++ b/src-tauri/resources/default-skills/video_generate/SKILL.md @@ -0,0 +1,34 @@ +--- +name: video_generate +description: 提交视频生成任务,并触发前端视频生成流程。 +allowed-tools: proxycast_create_video_generation_task +argument-hint: 输入主题、受众、平台、时长、画幅、风格、素材来源。 +when-to-use: 用户要求生成视频,或将现有文稿改编为短视频。 +version: 1.0.1 +execution-mode: prompt +--- + +你是 ProxyCast 的视频任务编排助手。 + +## 工作目标 + +将用户需求整理成“可执行的视频任务”,交由后续视频流程处理,不要伪造“已生成完成”的结果。 + +## 执行规则 + +- 先吸收用户输入、当前会话上下文、已有文稿与素材引用。 +- 上下文不足时,最多补问 1 个关键问题(例如时长或画幅)。 +- 输出聚焦“镜头意图 + 生成参数”,不要写成长文。 +- 必须调用 `proxycast_create_video_generation_task` 创建真实任务。 +- `projectId` 必须来自当前工作区项目;不要虚构 providerId/model。 +- 禁止伪造“视频已生成完成”。 + +## 输出格式(固定) + +仅输出任务创建结果摘要(不要再写 ``): + +- 任务类型:video_generate +- 任务 ID:{task_id} +- Provider:{provider_id} +- 模型:{model} +- 状态:{pending/processing/...} diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 7937cd92d..a00f79f87 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -29,6 +29,7 @@ use crate::logger; use crate::mcp::McpManagerState; use crate::plugin; use crate::services::heartbeat_service::{HeartbeatService, HeartbeatServiceState}; +use crate::skills::ensure_default_local_skills; use crate::telemetry; use crate::voice::recording_service::{create_recording_service_state, RecordingServiceState}; use proxycast_core::config::{Config, ConfigManager}; @@ -240,6 +241,20 @@ pub fn init_states(config: &Config) -> Result { database::dao::skills::SkillDao::init_default_skill_repos(&conn) .map_err(|e| format!("初始化默认技能仓库失败: {e}"))?; } + match ensure_default_local_skills() { + Ok(installed) if installed.is_empty() => { + tracing::info!("[Bootstrap] 默认本地 Skills 已存在,跳过写入"); + } + Ok(installed) => { + tracing::info!( + "[Bootstrap] 默认本地 Skills 安装完成: {}", + installed.join(", ") + ); + } + Err(error) => { + tracing::warn!("[Bootstrap] 安装默认本地 Skills 失败: {}", error); + } + } // 初始化调度器表,避免运行期健康检查出现缺表错误 if let Err(error) = AgentScheduler::init_tables(&db) { diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index e825e0a83..9501d6201 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -985,6 +985,7 @@ pub fn run() { // Execution run commands commands::execution_run_cmd::execution_run_list, commands::execution_run_cmd::execution_run_get, + commands::execution_run_cmd::execution_run_get_theme_workbench_state, // Ecommerce Review Reply commands commands::ecommerce_review_reply_cmd::execute_ecommerce_review_reply, // Provider Pool commands @@ -1204,6 +1205,7 @@ pub fn run() { commands::aster_agent_cmd::aster_session_delete, commands::aster_agent_cmd::aster_agent_confirm, commands::aster_agent_cmd::aster_agent_submit_elicitation_response, + commands::theme_context_cmd::aster_agent_theme_context_search, // Models config commands commands::models_cmd::get_models_config, commands::models_cmd::save_models_config, @@ -1358,10 +1360,18 @@ pub fn run() { commands::session_files_cmd::session_files_update_meta, commands::session_files_cmd::session_files_save_file, commands::session_files_cmd::session_files_read_file, + commands::session_files_cmd::session_files_resolve_file_path, commands::session_files_cmd::session_files_delete_file, commands::session_files_cmd::session_files_list_files, commands::session_files_cmd::session_files_cleanup_expired, commands::session_files_cmd::session_files_cleanup_empty, + // Image Upload commands + commands::image_upload_cmd::upload_image_to_session, + commands::image_upload_cmd::read_image_from_session, + // Document Import commands + commands::document_import_cmd::import_document, + commands::document_import_cmd::import_document_to_session, + commands::document_import_cmd::save_exported_document, // General Chat commands commands::general_chat_cmd::general_chat_create_session, commands::general_chat_cmd::general_chat_list_sessions, @@ -1462,6 +1472,7 @@ pub fn run() { // Content commands commands::content_cmd::content_create, commands::content_cmd::content_get, + commands::content_cmd::content_get_theme_workbench_document_state, commands::content_cmd::content_list, commands::content_cmd::content_update, commands::content_cmd::content_delete, diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index a645a0825..d3f54b2a1 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -8,6 +8,7 @@ use tauri::{App, Manager}; // use crate::agent::tools::{set_term_scrollback_tool_app_handle, set_terminal_tool_app_handle}; use crate::agent::AsterAgentState; use crate::database; +use crate::skills::ensure_default_local_skills; use crate::telemetry; use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot}; use proxycast_scheduler::AgentScheduler; @@ -79,6 +80,17 @@ pub fn setup_app( database::dao::skills::SkillDao::init_default_skill_repos(&conn) .expect("Failed to initialize default skill repos"); } + match ensure_default_local_skills() { + Ok(installed) if installed.is_empty() => { + tracing::info!("[启动] 默认本地 Skills 已存在,跳过写入"); + } + Ok(installed) => { + tracing::info!("[启动] 默认本地 Skills 安装完成: {}", installed.join(", ")); + } + Err(error) => { + tracing::warn!("[启动] 安装默认本地 Skills 失败: {}", error); + } + } // 初始化调度器数据库表 if let Err(e) = AgentScheduler::init_tables(&db) { diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 0f4a32666..401c6b9a7 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -9,10 +9,11 @@ use crate::agent::{ AsterAgentState, AsterAgentWrapper, HeartbeatServiceAdapter, SessionDetail, SessionInfo, TauriAgentEvent, }; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::webview_cmd::{ browser_execute_action_global, BrowserActionRequest, BrowserBackendType, }; -use crate::config::GlobalConfigManagerState; +use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; use crate::database::dao::agent::AgentDao; use crate::database::DbConnection; use crate::mcp::{McpManagerState, McpServerConfig}; @@ -48,7 +49,11 @@ use proxycast_agent::request_tool_policy::{ merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, }; +use proxycast_services::api_key_provider_service::ApiKeyProviderService; use proxycast_services::mcp_service::McpService; +use proxycast_services::video_generation_service::{ + CreateVideoGenerationRequest, VideoGenerationService, +}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -65,6 +70,20 @@ const WORKSPACE_SANDBOX_STRICT_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_STRICT"; const WORKSPACE_SANDBOX_NOTIFY_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK"; const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback"; const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created"; +const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image"; +const SOCIAL_IMAGE_DEFAULT_MODEL: &str = "gemini-3-pro-image-preview"; +const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024"; +const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url"; +const PROXYCAST_CREATE_VIDEO_TASK_TOOL_NAME: &str = "proxycast_create_video_generation_task"; +const PROXYCAST_CREATE_BROADCAST_TASK_TOOL_NAME: &str = + "proxycast_create_broadcast_generation_task"; +const PROXYCAST_CREATE_COVER_TASK_TOOL_NAME: &str = "proxycast_create_cover_generation_task"; +const PROXYCAST_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str = + "proxycast_create_modal_resource_search_task"; +const PROXYCAST_CREATE_IMAGE_TASK_TOOL_NAME: &str = "proxycast_create_image_generation_task"; +const PROXYCAST_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "proxycast_create_url_parse_task"; +const PROXYCAST_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "proxycast_create_typesetting_task"; +const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】"; static SHARED_TASK_MANAGER: OnceLock> = OnceLock::new(); @@ -339,6 +358,113 @@ pub struct AsterChatRequest { /// 执行策略(react / code_orchestrated / auto) #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, + /// 自动续写策略(用于文稿续写等场景) + #[serde(default, alias = "autoContinue")] + pub auto_continue: Option, +} + +/// 自动续写参数 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AutoContinuePayload { + /// 主开关 + pub enabled: bool, + /// 快速模式 + #[serde(default, alias = "fastModeEnabled")] + pub fast_mode_enabled: bool, + /// 续写长度:0=短、1=中、2=长 + #[serde(default, alias = "continuationLength")] + pub continuation_length: u8, + /// 灵敏度:0-100 + #[serde(default)] + pub sensitivity: u8, + /// 来源标识 + #[serde(default)] + pub source: Option, +} + +impl AutoContinuePayload { + fn normalized(mut self) -> Self { + self.continuation_length = self.continuation_length.min(2); + self.sensitivity = self.sensitivity.min(100); + self.source = self + .source + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self + } + + fn length_instruction(&self) -> &'static str { + match self.continuation_length.min(2) { + 0 => "短(补全 1-2 段,聚焦核心信息)", + 1 => "中(补全 3-5 段,兼顾结构与细节)", + _ => "长(扩展为可发布草稿,结构完整)", + } + } + + fn sensitivity_instruction(&self) -> &'static str { + match self.sensitivity.min(100) { + 0..=33 => "低:优先稳健延续原文表达", + 34..=66 => "中:保持一致性并适度优化表达", + _ => "高:在不偏题前提下积极补充观点亮点", + } + } +} + +fn build_auto_continue_system_prompt(config: &AutoContinuePayload) -> String { + let mode_instruction = if config.fast_mode_enabled { + "快速模式:优先产出可用结果,减少解释与冗余。" + } else { + "标准模式:兼顾可读性、完整性与发布可用性。" + }; + let source = config + .source + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("document_canvas"); + + format!( + "{AUTO_CONTINUE_PROMPT_MARKER}\n\ +执行来源:{source}\n\ +执行要求:\n\ +1. 本轮任务是“基于已有文稿的续写”,不得重复已有内容。\n\ +2. 从现有结尾自然衔接,保持原文语气、受众和主题方向。\n\ +3. 续写长度:{}。\n\ +4. 灵敏度({}%):{}。\n\ +5. {}\n\ +6. 输出正文时不要显式提及你看到了该策略配置。", + config.length_instruction(), + config.sensitivity, + config.sensitivity_instruction(), + mode_instruction, + ) +} + +fn merge_system_prompt_with_auto_continue( + base_prompt: Option, + auto_continue: Option<&AutoContinuePayload>, +) -> Option { + let Some(config) = auto_continue else { + return base_prompt; + }; + if !config.enabled { + return base_prompt; + } + + let auto_continue_prompt = build_auto_continue_system_prompt(config); + + match base_prompt { + Some(base) => { + if base.contains(AUTO_CONTINUE_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(auto_continue_prompt) + } else { + Some(format!("{base}\n\n{auto_continue_prompt}")) + } + } + None => Some(auto_continue_prompt), + } } /// Agent 执行策略 @@ -1005,6 +1131,976 @@ impl Tool for ProxycastBrowserMcpTool { } } +#[derive(Clone)] +struct SocialGenerateCoverImageTool { + config_manager: Arc, + client: reqwest::Client, +} + +impl SocialGenerateCoverImageTool { + fn new(config_manager: Arc) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(180)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + config_manager, + client, + } + } + + fn normalize_server_host(host: &str) -> String { + let trimmed = host.trim(); + if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" { + return "127.0.0.1".to_string(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + return trimmed.to_string(); + } + if trimmed.contains(':') { + return format!("[{trimmed}]"); + } + trimmed.to_string() + } + + fn parse_non_empty_string( + params: &serde_json::Value, + key: &str, + default: Option<&str>, + ) -> Option { + if let Some(value) = params.get(key).and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + default.map(ToString::to_string) + } + + fn extract_first_image_payload( + response_body: &serde_json::Value, + ) -> Result<(Option, Option, Option), String> { + let data = response_body + .get("data") + .and_then(|v| v.as_array()) + .ok_or_else(|| "图像接口返回缺少 data 字段".to_string())?; + + let first = data + .first() + .ok_or_else(|| "图像接口返回 data 为空".to_string())?; + + let image_url = first + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let image_b64 = first + .get("b64_json") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let revised_prompt = first + .get("revised_prompt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok((image_url, image_b64, revised_prompt)) + } +} + +#[async_trait] +impl Tool for SocialGenerateCoverImageTool { + fn name(&self) -> &str { + SOCIAL_IMAGE_TOOL_NAME + } + + fn description(&self) -> &str { + "为社媒文章生成封面图,内部复用 ProxyCast 的 /v1/images/generations 能力。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "图片描述词,建议包含主体、风格、氛围、构图。" + }, + "model": { + "type": "string", + "description": "可选模型名;不传则使用默认图像模型。" + }, + "size": { + "type": "string", + "description": "图片尺寸,例如 1024x1024、1024x1792。" + }, + "response_format": { + "type": "string", + "enum": ["url", "b64_json"], + "description": "返回格式,默认 url。" + } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["image", "social-media", "cover"], + "allowed_callers": ["assistant", "skill"], + "input_examples": [ + { + "prompt": "科技感蓝紫渐变背景,一位年轻创作者在笔记本前沉思,暖色轮廓光,简洁社媒封面风格", + "size": "1024x1024" + } + ] + } + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(180)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let prompt = Self::parse_non_empty_string(¶ms, "prompt", None).ok_or_else(|| { + ToolError::invalid_params("参数 prompt 必填,且不能为空字符串".to_string()) + })?; + + let runtime_config = self.config_manager.config(); + let model = + Self::parse_non_empty_string(¶ms, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL)) + .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string()); + let size = Self::parse_non_empty_string( + ¶ms, + "size", + runtime_config.image_gen.default_size.as_deref(), + ) + .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_SIZE.to_string()); + let response_format = Self::parse_non_empty_string( + ¶ms, + "response_format", + Some(SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT), + ) + .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT.to_string()); + + if response_format != "url" && response_format != "b64_json" { + return Err(ToolError::invalid_params( + "response_format 仅支持 url 或 b64_json".to_string(), + )); + } + + let server_host = Self::normalize_server_host(&runtime_config.server.host); + let endpoint = format!( + "http://{}:{}/v1/images/generations", + server_host, runtime_config.server.port + ); + let request_body = serde_json::json!({ + "prompt": prompt, + "model": model, + "n": 1, + "size": size, + "response_format": response_format + }); + + let response = self + .client + .post(&endpoint) + .header( + "Authorization", + format!("Bearer {}", runtime_config.server.api_key), + ) + .json(&request_body) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("调用图像接口失败: {e}")))?; + + let status = response.status(); + let response_body: serde_json::Value = response + .json() + .await + .map_err(|e| ToolError::execution_failed(format!("图像接口响应解析失败: {e}")))?; + + if !status.is_success() { + let error_message = response_body + .get("error") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("图像生成失败") + .to_string(); + let error_code = response_body + .get("error") + .and_then(|v| v.get("code")) + .and_then(|v| v.as_str()) + .unwrap_or("image_generation_failed") + .to_string(); + let result_payload = serde_json::json!({ + "success": false, + "error_code": error_code, + "error_message": error_message, + "status": status.as_u16(), + "retryable": status.is_server_error() || status.as_u16() == 429 + }); + return Ok(ToolResult::error(result_payload.to_string()) + .with_metadata("result", result_payload)); + } + + let (image_url, image_b64, revised_prompt) = + Self::extract_first_image_payload(&response_body) + .map_err(ToolError::execution_failed)?; + + if image_url.is_none() && image_b64.is_none() { + return Err(ToolError::execution_failed( + "图像接口返回中未找到 url 或 b64_json".to_string(), + )); + } + + let result_payload = serde_json::json!({ + "success": true, + "image_url": image_url, + "b64_json": image_b64, + "revised_prompt": revised_prompt, + "model": request_body.get("model").cloned(), + "size": request_body.get("size").cloned(), + "response_format": request_body.get("response_format").cloned() + }); + let output = serde_json::to_string_pretty(&result_payload) + .unwrap_or_else(|_| result_payload.to_string()); + Ok(ToolResult::success(output).with_metadata("result", result_payload)) + } +} + +fn is_safe_relative_path(path: &Path) -> bool { + if path.is_absolute() { + return false; + } + !path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) +} + +fn resolve_output_relative_path( + task_type: &str, + output_path: Option<&str>, +) -> Result { + if let Some(raw) = output_path { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::invalid_params( + "outputPath 不能为空字符串".to_string(), + )); + } + let candidate = PathBuf::from(trimmed); + if !is_safe_relative_path(&candidate) { + return Err(ToolError::invalid_params( + "outputPath 必须是安全的相对路径,且不能包含 '..'".to_string(), + )); + } + return Ok(candidate); + } + + let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let suffix = uuid::Uuid::new_v4().simple().to_string(); + Ok(PathBuf::from(".proxycast") + .join("tasks") + .join(task_type) + .join(format!("{timestamp}-{suffix}.json"))) +} + +fn submit_creation_task_record( + app_handle: &AppHandle, + context: &ToolContext, + task_type: &str, + title: Option, + payload: serde_json::Value, + output_path: Option<&str>, +) -> Result { + let output_rel_path = resolve_output_relative_path(task_type, output_path)?; + let output_abs_path = context.working_directory.join(&output_rel_path); + + let parent = output_abs_path + .parent() + .ok_or_else(|| ToolError::execution_failed("无法解析任务文件父目录".to_string()))?; + std::fs::create_dir_all(parent) + .map_err(|error| ToolError::execution_failed(format!("创建任务目录失败: {error}")))?; + + let task_id = uuid::Uuid::new_v4().to_string(); + let task_record = serde_json::json!({ + "task_id": task_id, + "task_type": task_type, + "title": title, + "payload": payload, + "status": "pending_submit", + "created_at": chrono::Utc::now().to_rfc3339() + }); + let task_content = + serde_json::to_string_pretty(&task_record).unwrap_or_else(|_| task_record.to_string()); + + std::fs::write(&output_abs_path, task_content.as_bytes()) + .map_err(|error| ToolError::execution_failed(format!("写入任务文件失败: {error}")))?; + + let emitted_payload = serde_json::json!({ + "task_id": task_id, + "task_type": task_type, + "path": output_rel_path.to_string_lossy().to_string(), + "absolute_path": output_abs_path.to_string_lossy().to_string() + }); + if let Err(error) = app_handle.emit("proxycast://creation_task_submitted", &emitted_payload) { + tracing::warn!( + "[AsterAgent] creation_task_submitted 事件发送失败: {}", + error + ); + } + + let output_payload = serde_json::json!({ + "success": true, + "task_id": task_id, + "task_type": task_type, + "path": output_rel_path.to_string_lossy().to_string(), + "absolute_path": output_abs_path.to_string_lossy().to_string(), + "record": task_record + }); + let output = serde_json::to_string_pretty(&output_payload) + .unwrap_or_else(|_| output_payload.to_string()); + Ok(ToolResult::success(output) + .with_metadata("task_id", serde_json::json!(task_id)) + .with_metadata("task_type", serde_json::json!(task_type)) + .with_metadata("path", serde_json::json!(output_abs_path.to_string_lossy()))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BroadcastTaskInput { + content: String, + #[serde(default)] + title: Option, + #[serde(default)] + audience: Option, + #[serde(default)] + tone: Option, + #[serde(default)] + duration_hint_minutes: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct ProxycastCreateBroadcastTaskTool { + app_handle: AppHandle, +} + +impl ProxycastCreateBroadcastTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for ProxycastCreateBroadcastTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_BROADCAST_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建播客内容整理任务(broadcast_generate)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "content": { "type": "string", "description": "可播报正文内容。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "audience": { "type": "string", "description": "目标听众(可选)。" }, + "tone": { "type": "string", "description": "语气风格(可选)。" }, + "durationHintMinutes": { "type": "integer", "minimum": 1, "maximum": 180, "description": "建议时长(分钟,可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["content"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["broadcast", "task", "creation"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: BroadcastTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.content.trim().is_empty() { + return Err(ToolError::invalid_params( + "content 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "content": input.content, + "audience": input.audience, + "tone": input.tone, + "durationHintMinutes": input.duration_hint_minutes + }); + submit_creation_task_record( + &self.app_handle, + context, + "broadcast_generate", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverTaskInput { + prompt: String, + #[serde(default)] + title: Option, + #[serde(default)] + platform: Option, + #[serde(default)] + size: Option, + #[serde(default)] + image_url: Option, + #[serde(default)] + status: Option, + #[serde(default)] + remark: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct ProxycastCreateCoverTaskTool { + app_handle: AppHandle, +} + +impl ProxycastCreateCoverTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for ProxycastCreateCoverTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_COVER_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建封面生成任务记录(cover_generate)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "封面提示词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "platform": { "type": "string", "description": "目标平台(可选)。" }, + "size": { "type": "string", "description": "尺寸(可选)。" }, + "imageUrl": { "type": "string", "description": "生成后的封面 URL(可选)。" }, + "status": { "type": "string", "description": "状态(成功/失败,可选)。" }, + "remark": { "type": "string", "description": "备注(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["cover", "image", "task"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: CoverTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.prompt.trim().is_empty() { + return Err(ToolError::invalid_params( + "prompt 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "prompt": input.prompt, + "platform": input.platform, + "size": input.size, + "imageUrl": input.image_url, + "status": input.status, + "remark": input.remark + }); + submit_creation_task_record( + &self.app_handle, + context, + "cover_generate", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceSearchTaskInput { + resource_type: String, + query: String, + #[serde(default)] + title: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + count: Option, + #[serde(default)] + filters: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct ProxycastCreateResourceSearchTaskTool { + app_handle: AppHandle, +} + +impl ProxycastCreateResourceSearchTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for ProxycastCreateResourceSearchTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建资源检索任务(modal_resource_search)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "resourceType": { "type": "string", "description": "资源类型,例如 image/bgm/sfx。" }, + "query": { "type": "string", "description": "检索关键词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "usage": { "type": "string", "description": "用途说明(可选)。" }, + "count": { "type": "integer", "minimum": 1, "maximum": 50, "description": "候选数量(可选)。" }, + "filters": { "type": "object", "description": "过滤条件(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["resourceType", "query"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["resource", "search", "task"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: ResourceSearchTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.resource_type.trim().is_empty() || input.query.trim().is_empty() { + return Err(ToolError::invalid_params( + "resourceType/query 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "resourceType": input.resource_type, + "query": input.query, + "usage": input.usage, + "count": input.count, + "filters": input.filters + }); + submit_creation_task_record( + &self.app_handle, + context, + "modal_resource_search", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageTaskInput { + prompt: String, + #[serde(default)] + title: Option, + #[serde(default)] + style: Option, + #[serde(default)] + size: Option, + #[serde(default)] + count: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct ProxycastCreateImageTaskTool { + app_handle: AppHandle, +} + +impl ProxycastCreateImageTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for ProxycastCreateImageTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_IMAGE_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建图片生成任务(image_generate)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "图像提示词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "style": { "type": "string", "description": "风格(可选)。" }, + "size": { "type": "string", "description": "尺寸(可选)。" }, + "count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" }, + "usage": { "type": "string", "description": "用途(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["image", "task", "generation"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: ImageTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.prompt.trim().is_empty() { + return Err(ToolError::invalid_params( + "prompt 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "prompt": input.prompt, + "style": input.style, + "size": input.size, + "count": input.count, + "usage": input.usage + }); + submit_creation_task_record( + &self.app_handle, + context, + "image_generate", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UrlParseTaskInput { + url: String, + #[serde(default)] + title: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + key_points: Option>, + #[serde(default)] + extract_status: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct ProxycastCreateUrlParseTaskTool { + app_handle: AppHandle, +} + +impl ProxycastCreateUrlParseTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for ProxycastCreateUrlParseTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_URL_PARSE_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建链接解析任务(url_parse)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "目标 URL。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "summary": { "type": "string", "description": "摘要(可选)。" }, + "keyPoints": { "type": "array", "items": { "type": "string" }, "description": "关键要点(可选)。" }, + "extractStatus": { "type": "string", "description": "提取状态(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["url"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["url", "parse", "task"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: UrlParseTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.url.trim().is_empty() { + return Err(ToolError::invalid_params("url 不能为空字符串".to_string())); + } + let payload = serde_json::json!({ + "url": input.url, + "summary": input.summary, + "keyPoints": input.key_points, + "extractStatus": input.extract_status + }); + submit_creation_task_record( + &self.app_handle, + context, + "url_parse", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TypesettingTaskInput { + content: String, + #[serde(default)] + title: Option, + #[serde(default)] + target_platform: Option, + #[serde(default)] + rules: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct ProxycastCreateTypesettingTaskTool { + app_handle: AppHandle, +} + +impl ProxycastCreateTypesettingTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for ProxycastCreateTypesettingTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_TYPESETTING_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建排版优化任务(typesetting)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "content": { "type": "string", "description": "待排版内容。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "targetPlatform": { "type": "string", "description": "目标平台(可选)。" }, + "rules": { "type": "object", "description": "排版规则(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["content"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["typesetting", "task", "text"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: TypesettingTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.content.trim().is_empty() { + return Err(ToolError::invalid_params( + "content 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "content": input.content, + "targetPlatform": input.target_platform, + "rules": input.rules + }); + submit_creation_task_record( + &self.app_handle, + context, + "typesetting", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Clone)] +struct ProxycastCreateVideoGenerationTaskTool { + db: DbConnection, + api_key_provider_service: Arc, +} + +impl ProxycastCreateVideoGenerationTaskTool { + fn new(db: DbConnection, api_key_provider_service: Arc) -> Self { + Self { + db, + api_key_provider_service, + } + } +} + +#[async_trait] +impl Tool for ProxycastCreateVideoGenerationTaskTool { + fn name(&self) -> &str { + PROXYCAST_CREATE_VIDEO_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "调用 ProxyCast 视频任务服务,创建真实的视频生成任务。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "项目 ID。" }, + "providerId": { "type": "string", "description": "视频服务 Provider ID。" }, + "model": { "type": "string", "description": "模型名。" }, + "prompt": { "type": "string", "description": "视频生成提示词。" }, + "aspectRatio": { "type": "string", "description": "画幅比例,例如 16:9、9:16。" }, + "resolution": { "type": "string", "description": "分辨率,例如 720p。" }, + "duration": { "type": "integer", "description": "时长(秒)。" }, + "imageUrl": { "type": "string", "description": "首帧图 URL(可选)。" }, + "endImageUrl": { "type": "string", "description": "末帧图 URL(可选)。" }, + "seed": { "type": "integer", "description": "随机种子(可选)。" }, + "generateAudio": { "type": "boolean", "description": "是否生成音频(可选)。" }, + "cameraFixed": { "type": "boolean", "description": "是否固定镜头(可选)。" } + }, + "required": ["projectId", "providerId", "model", "prompt"], + "additionalProperties": false, + "x-proxycast": { + "always_visible": true, + "tags": ["video", "task", "generation"], + "allowed_callers": ["assistant", "skill"], + "input_examples": [ + { + "projectId": "project-demo", + "providerId": "volcengine", + "model": "doubao-seedance-1-0-pro-250528", + "prompt": "未来城市清晨,镜头缓慢推进,电影感", + "aspectRatio": "16:9", + "duration": 5 + } + ] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let request: CreateVideoGenerationRequest = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if request.project_id.trim().is_empty() + || request.provider_id.trim().is_empty() + || request.model.trim().is_empty() + || request.prompt.trim().is_empty() + { + return Err(ToolError::invalid_params( + "projectId/providerId/model/prompt 均不能为空".to_string(), + )); + } + + let service = VideoGenerationService::new(); + let created = service + .create_task(&self.db, self.api_key_provider_service.as_ref(), request) + .await + .map_err(|error| ToolError::execution_failed(format!("创建视频任务失败: {error}")))?; + + let payload = serde_json::json!({ + "success": true, + "task": created + }); + let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()); + Ok(ToolResult::success(output)) + } +} + struct ToolSearchBridgeTool { registry: Arc>, } @@ -1299,6 +2395,60 @@ fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegis } } +fn register_social_image_tool_to_registry( + registry: &mut aster::tools::ToolRegistry, + config_manager: Arc, +) { + if registry.contains(SOCIAL_IMAGE_TOOL_NAME) { + return; + } + registry.register(Box::new(SocialGenerateCoverImageTool::new(config_manager))); +} + +fn register_creation_task_tools_to_registry( + registry: &mut aster::tools::ToolRegistry, + db: DbConnection, + api_key_provider_service: Arc, + app_handle: AppHandle, +) { + if !registry.contains(PROXYCAST_CREATE_VIDEO_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateVideoGenerationTaskTool::new( + db.clone(), + api_key_provider_service.clone(), + ))); + } + if !registry.contains(PROXYCAST_CREATE_BROADCAST_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateBroadcastTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(PROXYCAST_CREATE_COVER_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateCoverTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(PROXYCAST_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateResourceSearchTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(PROXYCAST_CREATE_IMAGE_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateImageTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(PROXYCAST_CREATE_URL_PARSE_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateUrlParseTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(PROXYCAST_CREATE_TYPESETTING_TASK_TOOL_NAME) { + registry.register(Box::new(ProxycastCreateTypesettingTaskTool::new( + app_handle, + ))); + } +} + fn register_tool_search_tool_to_registry( registry: &mut aster::tools::ToolRegistry, registry_arc: Arc>, @@ -1324,6 +2474,47 @@ pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Res Ok(()) } +pub async fn ensure_social_image_tool_registered( + state: &AsterAgentState, + config_manager: &GlobalConfigManagerState, +) -> Result<(), String> { + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .as_ref() + .ok_or_else(|| "Agent not initialized".to_string())?; + let registry_arc = agent.tool_registry().clone(); + drop(guard); + + let mut registry = registry_arc.write().await; + register_social_image_tool_to_registry(&mut registry, config_manager.0.clone()); + Ok(()) +} + +pub async fn ensure_creation_task_tools_registered( + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + app_handle: &AppHandle, +) -> Result<(), String> { + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .as_ref() + .ok_or_else(|| "Agent not initialized".to_string())?; + let registry_arc = agent.tool_registry().clone(); + drop(guard); + + let mut registry = registry_arc.write().await; + register_creation_task_tools_to_registry( + &mut registry, + db.clone(), + api_key_provider_service.0.clone(), + app_handle.clone(), + ); + Ok(()) +} + pub async fn ensure_tool_search_tool_registered(state: &AsterAgentState) -> Result<(), String> { let agent_arc = state.get_agent_arc(); let guard = agent_arc.read().await; @@ -1357,6 +2548,8 @@ fn build_workspace_shell_allow_pattern( async fn apply_workspace_sandbox_permissions( state: &AsterAgentState, config_manager: &GlobalConfigManagerState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, heartbeat_state: &HeartbeatServiceState, app_handle: &AppHandle, workspace_root: &str, @@ -1800,6 +2993,14 @@ async fn apply_workspace_sandbox_permissions( "tool_search", "three_stage_workflow", "heartbeat", + SOCIAL_IMAGE_TOOL_NAME, + PROXYCAST_CREATE_VIDEO_TASK_TOOL_NAME, + PROXYCAST_CREATE_BROADCAST_TASK_TOOL_NAME, + PROXYCAST_CREATE_COVER_TASK_TOOL_NAME, + PROXYCAST_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, + PROXYCAST_CREATE_IMAGE_TASK_TOOL_NAME, + PROXYCAST_CREATE_URL_PARSE_TASK_TOOL_NAME, + PROXYCAST_CREATE_TYPESETTING_TASK_TOOL_NAME, ] { permissions.push(ToolPermission { tool: tool_name.to_string(), @@ -1880,6 +3081,14 @@ async fn apply_workspace_sandbox_permissions( let heartbeat_tool = proxycast_agent::tools::HeartbeatTool::new(Arc::new(heartbeat_adapter)); registry.register(Box::new(heartbeat_tool)); + register_social_image_tool_to_registry(&mut registry, config_manager.0.clone()); + register_creation_task_tools_to_registry( + &mut registry, + db.clone(), + api_key_provider_service.0.clone(), + app_handle.clone(), + ); + // 注册浏览器 MCP 工具 register_browser_mcp_tools_to_registry(&mut registry); @@ -1900,6 +3109,7 @@ pub async fn aster_agent_chat_stream( app: AppHandle, state: State<'_, AsterAgentState>, db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, logs: State<'_, LogState>, config_manager: State<'_, GlobalConfigManagerState>, mcp_manager: State<'_, McpManagerState>, @@ -1929,6 +3139,7 @@ pub async fn aster_agent_chat_stream( tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); } } + ensure_social_image_tool_registered(state.inner(), config_manager.inner()).await?; // 直接使用前端传递的 session_id // ProxyCastSessionStore 会在 add_message 时自动创建不存在的 session @@ -1974,6 +3185,26 @@ pub async fn aster_agent_chat_stream( let workspace_root = ensured.root_path.to_string_lossy().to_string(); let runtime_config = config_manager.config(); apply_web_search_runtime_env(&runtime_config); + let auto_continue_config = request + .auto_continue + .clone() + .map(AutoContinuePayload::normalized); + let auto_continue_enabled = auto_continue_config + .as_ref() + .map(|config| config.enabled) + .unwrap_or(false); + if let Some(config) = auto_continue_config + .as_ref() + .filter(|config| config.enabled) + { + tracing::info!( + "[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}", + config.source, + config.fast_mode_enabled, + config.continuation_length, + config.sensitivity + ); + } if ensured.repaired { let warning_message = ensured.warning.unwrap_or_else(|| { @@ -2098,12 +3329,15 @@ pub async fn aster_agent_chat_stream( } }; - let merged_prompt = merge_system_prompt_with_request_tool_policy( - merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config), - &runtime_config, + let merged_prompt = merge_system_prompt_with_auto_continue( + merge_system_prompt_with_request_tool_policy( + merge_system_prompt_with_web_search( + merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config), + &runtime_config, + ), + &request_tool_policy, ), - &request_tool_policy, + auto_continue_config.as_ref(), ); (merged_prompt, persisted) @@ -2181,6 +3415,8 @@ pub async fn aster_agent_chat_stream( let sandbox_outcome = apply_workspace_sandbox_permissions( &state, config_manager.inner(), + db.inner(), + api_key_provider_service.inner(), heartbeat_state.inner(), &app, &workspace_root, @@ -2226,6 +3462,7 @@ pub async fn aster_agent_chat_stream( let tracker = ExecutionTracker::new(db.inner().clone()); let cancel_token = state.create_cancel_token(session_id).await; + let auto_continue_metadata = auto_continue_config.clone(); // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 let agent_arc = state.get_agent_arc(); @@ -2256,18 +3493,22 @@ pub async fn aster_agent_chat_stream( "execution_strategy": format!("{:?}", effective_strategy).to_lowercase(), "message_length": request.message.chars().count(), "web_search_enabled": request_tool_policy.effective_web_search, + "auto_continue_enabled": auto_continue_enabled, + "auto_continue": auto_continue_metadata, })), RunFinalizeOptions { success_metadata: Some(serde_json::json!({ "execution_strategy": format!("{:?}", effective_strategy).to_lowercase(), "workspace_id": workspace_id.clone(), "web_search_enabled": request_tool_policy.effective_web_search, + "auto_continue_enabled": auto_continue_enabled, })), error_code: Some("chat_stream_failed".to_string()), error_metadata: Some(serde_json::json!({ "execution_strategy": format!("{:?}", effective_strategy).to_lowercase(), "workspace_id": workspace_id.clone(), "web_search_enabled": request_tool_policy.effective_web_search, + "auto_continue_enabled": auto_continue_enabled, })), }, async { @@ -2655,6 +3896,7 @@ mod tests { assert_eq!(request.event_name, "agent_stream"); assert_eq!(request.workspace_id, "workspace-test"); assert_eq!(request.execution_strategy, None); + assert_eq!(request.auto_continue, None); } #[test] @@ -2688,6 +3930,63 @@ mod tests { assert_eq!(request.web_search, Some(true)); } + #[test] + fn test_aster_chat_request_deserialize_with_auto_continue_payload() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "auto_continue": { + "enabled": true, + "fast_mode_enabled": true, + "continuation_length": 2, + "sensitivity": 88, + "source": "document_canvas" + } + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + request.auto_continue, + Some(AutoContinuePayload { + enabled: true, + fast_mode_enabled: true, + continuation_length: 2, + sensitivity: 88, + source: Some("document_canvas".to_string()), + }) + ); + } + + #[test] + fn test_aster_chat_request_deserialize_with_auto_continue_camel_case_aliases() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "autoContinue": { + "enabled": true, + "fastModeEnabled": true, + "continuationLength": 1, + "sensitivity": 45 + } + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + request.auto_continue, + Some(AutoContinuePayload { + enabled: true, + fast_mode_enabled: true, + continuation_length: 1, + sensitivity: 45, + source: None, + }) + ); + } + #[test] fn test_aster_execution_strategy_default_is_auto() { assert_eq!( @@ -2773,6 +4072,37 @@ mod tests { assert_eq!(merged, base); } + #[test] + fn test_merge_system_prompt_with_auto_continue_appends_prompt() { + let config = AutoContinuePayload { + enabled: true, + fast_mode_enabled: false, + continuation_length: 1, + sensitivity: 55, + source: Some("theme_workbench_document_auto_continue".to_string()), + }; + let merged = + merge_system_prompt_with_auto_continue(Some("你是助手".to_string()), Some(&config)) + .expect("should contain merged prompt"); + assert!(merged.contains(AUTO_CONTINUE_PROMPT_MARKER)); + assert!(merged.contains("续写长度")); + assert!(merged.contains("theme_workbench_document_auto_continue")); + } + + #[test] + fn test_merge_system_prompt_with_auto_continue_skip_when_disabled() { + let config = AutoContinuePayload { + enabled: false, + fast_mode_enabled: false, + continuation_length: 1, + sensitivity: 55, + source: None, + }; + let base = Some("你是助手".to_string()); + let merged = merge_system_prompt_with_auto_continue(base.clone(), Some(&config)); + assert_eq!(merged, base); + } + #[test] fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() { let error = ReplyAttemptError { @@ -2950,6 +4280,70 @@ mod tests { assert!(exact > partial); } + #[test] + fn test_social_generate_cover_image_parse_non_empty_string() { + let params = serde_json::json!({ + "prompt": " 封面图描述 ", + "size": " " + }); + + let prompt = SocialGenerateCoverImageTool::parse_non_empty_string(¶ms, "prompt", None); + let size = SocialGenerateCoverImageTool::parse_non_empty_string( + ¶ms, + "size", + Some(SOCIAL_IMAGE_DEFAULT_SIZE), + ); + + assert_eq!(prompt, Some("封面图描述".to_string())); + assert_eq!(size, Some(SOCIAL_IMAGE_DEFAULT_SIZE.to_string())); + } + + #[test] + fn test_social_generate_cover_image_extract_first_image_payload() { + let response = serde_json::json!({ + "data": [ + { + "url": "https://example.com/image.png", + "revised_prompt": "优化后的提示词" + } + ] + }); + + let (image_url, image_b64, revised_prompt) = + SocialGenerateCoverImageTool::extract_first_image_payload(&response).unwrap(); + assert_eq!(image_url, Some("https://example.com/image.png".to_string())); + assert_eq!(image_b64, None); + assert_eq!(revised_prompt, Some("优化后的提示词".to_string())); + } + + #[test] + fn test_social_generate_cover_image_extract_first_image_payload_rejects_empty_data() { + let response = serde_json::json!({ "data": [] }); + let result = SocialGenerateCoverImageTool::extract_first_image_payload(&response); + + assert!(result.is_err()); + assert!(result + .err() + .unwrap_or_default() + .contains("图像接口返回 data 为空")); + } + + #[test] + fn test_social_generate_cover_image_normalize_server_host() { + assert_eq!( + SocialGenerateCoverImageTool::normalize_server_host("0.0.0.0"), + "127.0.0.1".to_string() + ); + assert_eq!( + SocialGenerateCoverImageTool::normalize_server_host("::"), + "127.0.0.1".to_string() + ); + assert_eq!( + SocialGenerateCoverImageTool::normalize_server_host(" localhost "), + "localhost".to_string() + ); + } + #[tokio::test] async fn test_tool_search_bridge_tool_end_to_end_filters_by_caller_and_deferred() { let registry = Arc::new(tokio::sync::RwLock::new(aster::tools::ToolRegistry::new())); diff --git a/src-tauri/src/commands/content_cmd.rs b/src-tauri/src/commands/content_cmd.rs index 0df6f8ce3..93e204dcf 100644 --- a/src-tauri/src/commands/content_cmd.rs +++ b/src-tauri/src/commands/content_cmd.rs @@ -10,6 +10,8 @@ use crate::database::DbConnection; use serde::{Deserialize, Serialize}; use tauri::State; +const THEME_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1"; + /// 内容列表项(用于前端展示) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContentListItem { @@ -78,6 +80,114 @@ impl From for ContentDetail { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeWorkbenchVersionState { + pub id: String, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + pub is_current: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeWorkbenchDocumentState { + pub content_id: String, + pub current_version_id: String, + pub version_count: usize, + pub versions: Vec, +} + +fn is_valid_topic_branch_status(status: &str) -> bool { + matches!(status, "in_progress" | "pending" | "merged" | "candidate") +} + +fn parse_theme_workbench_document_state( + content_id: &str, + metadata: Option<&serde_json::Value>, +) -> Option { + let metadata = metadata?.as_object()?; + let raw = metadata + .get(THEME_WORKBENCH_DOCUMENT_META_KEY)? + .as_object()?; + + let versions_raw = raw.get("versions")?.as_array()?; + if versions_raw.is_empty() { + return None; + } + + let current_version_id = raw.get("currentVersionId")?.as_str()?.trim().to_string(); + if current_version_id.is_empty() { + return None; + } + + let status_map = raw + .get("versionStatusMap") + .and_then(|value| value.as_object()) + .cloned() + .unwrap_or_default(); + + let versions: Vec = versions_raw + .iter() + .filter_map(|version| { + let version_obj = version.as_object()?; + let id = version_obj.get("id")?.as_str()?.trim().to_string(); + if id.is_empty() { + return None; + } + + let created_at = version_obj + .get("createdAt") + .and_then(|value| value.as_i64()) + .or_else(|| { + version_obj + .get("created_at") + .and_then(|value| value.as_i64()) + })?; + + let description = version_obj + .get("description") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + let status = status_map + .get(&id) + .and_then(|value| value.as_str()) + .filter(|value| is_valid_topic_branch_status(value)) + .map(ToString::to_string); + + Some(ThemeWorkbenchVersionState { + is_current: id == current_version_id, + id, + created_at, + description, + status, + }) + }) + .collect(); + + if versions.is_empty() { + return None; + } + + if !versions + .iter() + .any(|version| version.id == current_version_id) + { + return None; + } + + Some(ThemeWorkbenchDocumentState { + content_id: content_id.to_string(), + current_version_id, + version_count: versions.len(), + versions, + }) +} + /// 创建内容请求 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateContentRequest { @@ -163,6 +273,18 @@ pub async fn content_get( Ok(content.map(|c| c.into())) } +/// 获取主题工作台文稿版本状态(从 content.metadata 解析) +#[tauri::command] +pub async fn content_get_theme_workbench_document_state( + db: State<'_, DbConnection>, + id: String, +) -> Result, String> { + let manager = ContentManager::new(db.inner().clone()); + let content = manager.get(&id)?; + Ok(content + .and_then(|item| parse_theme_workbench_document_state(&item.id, item.metadata.as_ref()))) +} + /// 列出项目的所有内容 #[tauri::command] pub async fn content_list( @@ -237,3 +359,48 @@ pub async fn content_stats( let manager = ContentManager::new(db.inner().clone()); manager.get_project_stats(&project_id) } + +#[cfg(test)] +mod tests { + use super::{parse_theme_workbench_document_state, THEME_WORKBENCH_DOCUMENT_META_KEY}; + + #[test] + fn test_parse_theme_workbench_document_state_success() { + let metadata = serde_json::json!({ + THEME_WORKBENCH_DOCUMENT_META_KEY: { + "currentVersionId": "v2", + "versions": [ + { "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" }, + { "id": "v2", "createdAt": 1700000100000_i64, "description": "修订版" } + ], + "versionStatusMap": { + "v1": "merged", + "v2": "in_progress" + } + } + }); + + let parsed = parse_theme_workbench_document_state("content-1", Some(&metadata)) + .expect("should parse"); + assert_eq!(parsed.content_id, "content-1"); + assert_eq!(parsed.current_version_id, "v2"); + assert_eq!(parsed.version_count, 2); + assert_eq!(parsed.versions[0].status.as_deref(), Some("merged")); + assert!(parsed.versions[1].is_current); + } + + #[test] + fn test_parse_theme_workbench_document_state_rejects_invalid_current_version() { + let metadata = serde_json::json!({ + THEME_WORKBENCH_DOCUMENT_META_KEY: { + "currentVersionId": "v-not-exists", + "versions": [ + { "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" } + ], + "versionStatusMap": { "v1": "merged" } + } + }); + + assert!(parse_theme_workbench_document_state("content-1", Some(&metadata)).is_none()); + } +} diff --git a/src-tauri/src/commands/document_import_cmd.rs b/src-tauri/src/commands/document_import_cmd.rs new file mode 100644 index 000000000..84ea834ff --- /dev/null +++ b/src-tauri/src/commands/document_import_cmd.rs @@ -0,0 +1,120 @@ +//! 文档导入 Tauri 命令 +//! +//! 提供文档导入和解析功能。 + +use crate::commands::session_files_cmd::SessionFilesState; +use std::path::Path; +use tauri::State; + +/// 支持的文档格式 +const SUPPORTED_DOC_EXTENSIONS: &[&str] = &["md", "txt"]; + +/// 文档文件最大大小(5MB) +const MAX_DOC_SIZE: u64 = 5 * 1024 * 1024; + +/// 验证文件是否为支持的文档格式 +fn is_supported_document(file_path: &str) -> bool { + let path = Path::new(file_path); + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + return SUPPORTED_DOC_EXTENSIONS.contains(&ext_str.as_str()); + } + false +} + +/// 导入文档内容 +/// +/// # 参数 +/// - `file_path`: 本地文档文件路径 +/// +/// # 返回 +/// 返回文档的文本内容 +#[tauri::command] +pub async fn import_document(file_path: String) -> Result { + // 验证文件格式 + if !is_supported_document(&file_path) { + return Err(format!( + "不支持的文档格式。支持的格式:{}", + SUPPORTED_DOC_EXTENSIONS.join(", ") + )); + } + + // 检查文件是否存在 + let path = Path::new(&file_path); + if !path.exists() { + return Err("文件不存在".to_string()); + } + + // 检查文件大小 + let metadata = std::fs::metadata(path).map_err(|e| format!("读取文件元数据失败: {}", e))?; + if metadata.len() > MAX_DOC_SIZE { + return Err(format!( + "文档文件过大(最大 {}MB)", + MAX_DOC_SIZE / 1024 / 1024 + )); + } + + // 读取文件内容 + let content = std::fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?; + + Ok(content) +} + +/// 导入文档并保存到会话 +/// +/// # 参数 +/// - `session_id`: 会话ID +/// - `file_path`: 本地文档文件路径 +/// +/// # 返回 +/// 返回文档内容和保存的文件名 +#[tauri::command] +pub async fn import_document_to_session( + state: State<'_, SessionFilesState>, + session_id: String, + file_path: String, +) -> Result<(String, String), String> { + // 导入文档内容 + let content = import_document(file_path.clone()).await?; + + // 生成文件名 + let path = Path::new(&file_path); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or("无效的文件名")?; + + // 保存到会话文件系统 + super::session_files_cmd::session_files_save_file( + state, + session_id, + file_name.to_string(), + content.clone(), + )?; + + Ok((content, file_name.to_string())) +} + +/// 保存导出的文档到指定路径 +/// +/// # 参数 +/// - `file_path`: 用户选择的目标文件路径 +/// - `content`: 要写入的文本内容 +#[tauri::command] +pub async fn save_exported_document(file_path: String, content: String) -> Result<(), String> { + let path = Path::new(&file_path); + + if file_path.trim().is_empty() { + return Err("导出路径不能为空".to_string()); + } + + if let Some(parent) = path.parent() { + if parent.as_os_str().is_empty() { + return std::fs::write(path, content).map_err(|e| format!("保存导出文件失败: {}", e)); + } + std::fs::create_dir_all(parent).map_err(|e| format!("创建导出目录失败: {}", e))?; + } + + std::fs::write(path, content).map_err(|e| format!("保存导出文件失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/commands/ecommerce_review_reply_cmd.rs b/src-tauri/src/commands/ecommerce_review_reply_cmd.rs index 810399613..e773a818d 100644 --- a/src-tauri/src/commands/ecommerce_review_reply_cmd.rs +++ b/src-tauri/src/commands/ecommerce_review_reply_cmd.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use tauri::State; use crate::agent::AsterAgentState; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::skill_exec_cmd::{execute_skill, SkillExecutionResult}; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; @@ -46,6 +47,7 @@ pub struct EcommerceReviewReplyRequest { pub async fn execute_ecommerce_review_reply( app_handle: tauri::AppHandle, db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, config_manager: State<'_, GlobalConfigManagerState>, aster_state: State<'_, AsterAgentState>, request: EcommerceReviewReplyRequest, @@ -74,6 +76,7 @@ pub async fn execute_ecommerce_review_reply( execute_skill( app_handle, db, + api_key_provider_service, config_manager, aster_state, "ecommerce-review-reply".to_string(), diff --git a/src-tauri/src/commands/execution_run_cmd.rs b/src-tauri/src/commands/execution_run_cmd.rs index 0a0e66efe..6b5310992 100644 --- a/src-tauri/src/commands/execution_run_cmd.rs +++ b/src-tauri/src/commands/execution_run_cmd.rs @@ -2,11 +2,64 @@ //! //! 提供对 `agent_runs` 的只读查询能力,供前端查看 chat / skill / heartbeat 执行摘要。 -use crate::database::dao::agent_run::AgentRun; +use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; use crate::database::DbConnection; use crate::services::execution_tracker_service::ExecutionTracker; +use chrono::Utc; +use serde::Serialize; +use serde_json::Value; use tauri::State; +const STALE_RUN_TIMEOUT_SECONDS: i64 = 180; + +fn parse_run_time(raw: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(raw) + .ok() + .map(|parsed| parsed.with_timezone(&Utc)) +} + +fn collect_stale_run_ids(runs: &[AgentRun], now: chrono::DateTime) -> Vec { + runs.iter() + .filter(|run| matches!(run.status, AgentRunStatus::Running | AgentRunStatus::Queued)) + .filter_map(|run| { + let started_at = parse_run_time(run.started_at.as_str())?; + let elapsed_seconds = now.signed_duration_since(started_at).num_seconds(); + if elapsed_seconds > STALE_RUN_TIMEOUT_SECONDS { + Some(run.id.clone()) + } else { + None + } + }) + .collect() +} + +fn mark_stale_runs_as_timeout( + db: &DbConnection, + stale_run_ids: &[String], + finished_at: &str, +) -> Result<(), String> { + if stale_run_ids.is_empty() { + return Ok(()); + } + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + for run_id in stale_run_ids { + AgentRunDao::finish_run( + &conn, + run_id, + AgentRunStatus::Timeout, + finished_at, + None, + Some("run_stale_timeout"), + Some("运行状态已超时,自动回收"), + None, + ) + .map_err(|e| format!("回收超时运行记录失败: {e}"))?; + } + + Ok(()) +} + #[tauri::command] pub async fn execution_run_list( db: State<'_, DbConnection>, @@ -31,3 +84,486 @@ pub async fn execution_run_get( let tracker = ExecutionTracker::new(db.inner().clone()); tracker.get_run(id) } + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ThemeWorkbenchRunTodoItem { + pub run_id: String, + pub execution_id: Option, + pub session_id: Option, + pub artifact_paths: Vec, + pub title: String, + pub gate_key: String, + pub status: AgentRunStatus, + pub source: String, + pub source_ref: Option, + pub started_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ThemeWorkbenchRunTerminalItem { + pub run_id: String, + pub execution_id: Option, + pub session_id: Option, + pub artifact_paths: Vec, + pub title: String, + pub gate_key: String, + pub status: AgentRunStatus, + pub source: String, + pub source_ref: Option, + pub started_at: String, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ThemeWorkbenchRunState { + pub run_state: String, + pub current_gate_key: String, + pub queue_items: Vec, + pub latest_terminal: Option, + pub updated_at: String, +} + +fn normalize_gate_key(raw: &str) -> Option { + let normalized = raw.trim().to_lowercase(); + match normalized.as_str() { + "topic_select" | "write_mode" | "publish_confirm" => Some(normalized), + _ => None, + } +} + +fn infer_gate_key_from_probe(probe: &str) -> String { + let normalized = probe.to_lowercase(); + if normalized.contains("publish") + || normalized.contains("adapt") + || normalized.contains("distribution") + || normalized.contains("release") + || normalized.contains("发布") + || normalized.contains("分发") + || normalized.contains("平台适配") + { + return "publish_confirm".to_string(); + } + if normalized.contains("topic") + || normalized.contains("research") + || normalized.contains("trend") + || normalized.contains("idea") + || normalized.contains("选题") + || normalized.contains("方向") + || normalized.contains("调研") + || normalized.contains("洞察") + { + return "topic_select".to_string(); + } + "write_mode".to_string() +} + +fn derive_run_title(run: &AgentRun) -> String { + let parsed_metadata = run + .metadata + .as_ref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + + let skill_title = parsed_metadata + .as_ref() + .and_then(|value| value.get("skill_name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("执行技能 {value}")); + if let Some(title) = skill_title { + return title; + } + + let task_title = parsed_metadata + .as_ref() + .and_then(|value| value.get("task_name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("执行任务 {value}")); + if let Some(title) = task_title { + return title; + } + + let source_ref_title = run + .source_ref + .as_ref() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("运行节点 {value}")); + if let Some(title) = source_ref_title { + return title; + } + + match run.source.as_str() { + "skill" => "执行主题工作台技能".to_string(), + "heartbeat" => "执行定时任务".to_string(), + _ => "执行主题工作台编排".to_string(), + } +} + +fn derive_run_gate_key(run: &AgentRun, title: &str) -> String { + let parsed_metadata = run + .metadata + .as_ref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + + if let Some(value) = parsed_metadata + .as_ref() + .and_then(|value| value.get("gate_key")) + .and_then(Value::as_str) + .and_then(normalize_gate_key) + { + return value; + } + + let metadata_probe = parsed_metadata + .as_ref() + .map(|value| value.to_string()) + .unwrap_or_default(); + let source_ref_probe = run.source_ref.clone().unwrap_or_default(); + let probe = format!( + "{} {} {} {}", + title, source_ref_probe, run.source, metadata_probe + ); + infer_gate_key_from_probe(probe.as_str()) +} + +fn derive_current_gate_key(queue_items: &[ThemeWorkbenchRunTodoItem]) -> String { + queue_items + .iter() + .find(|item| item.status == AgentRunStatus::Running) + .map(|item| item.gate_key.clone()) + .or_else(|| queue_items.first().map(|item| item.gate_key.clone())) + .unwrap_or_else(|| "idle".to_string()) +} + +fn derive_run_execution_id(run: &AgentRun) -> Option { + let parsed_metadata = run + .metadata + .as_ref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + + parsed_metadata + .as_ref() + .and_then(|value| { + value + .get("execution_id") + .or_else(|| value.get("version_id")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn derive_run_artifact_paths(run: &AgentRun) -> Vec { + let parsed_metadata = run + .metadata + .as_ref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + + parsed_metadata + .as_ref() + .and_then(|value| value.get("artifact_paths")) + .and_then(Value::as_array) + .map(|paths| { + paths + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +#[tauri::command] +pub async fn execution_run_get_theme_workbench_state( + db: State<'_, DbConnection>, + session_id: String, + limit: Option, +) -> Result { + let trimmed_session_id = session_id.trim(); + if trimmed_session_id.is_empty() { + return Err("session_id 不能为空".to_string()); + } + + let safe_limit = limit.unwrap_or(3).clamp(1, 10); + let tracker = ExecutionTracker::new(db.inner().clone()); + let mut runs = tracker.list_runs_by_session(trimmed_session_id, safe_limit * 5)?; + let now = Utc::now(); + let stale_run_ids = collect_stale_run_ids(runs.as_slice(), now); + if !stale_run_ids.is_empty() { + mark_stale_runs_as_timeout(db.inner(), stale_run_ids.as_slice(), &now.to_rfc3339())?; + runs = tracker.list_runs_by_session(trimmed_session_id, safe_limit * 5)?; + } + + let queue_items: Vec = runs + .iter() + .filter(|run| matches!(run.status, AgentRunStatus::Running | AgentRunStatus::Queued)) + .take(safe_limit) + .map(|run| { + let title = derive_run_title(run); + let gate_key = derive_run_gate_key(run, title.as_str()); + ThemeWorkbenchRunTodoItem { + run_id: run.id.clone(), + execution_id: derive_run_execution_id(run), + session_id: run.session_id.clone(), + artifact_paths: derive_run_artifact_paths(run), + title, + gate_key, + status: run.status.clone(), + source: run.source.clone(), + source_ref: run.source_ref.clone(), + started_at: run.started_at.clone(), + } + }) + .collect(); + + let run_state = if queue_items.is_empty() { + "idle".to_string() + } else { + "auto_running".to_string() + }; + let current_gate_key = derive_current_gate_key(queue_items.as_slice()); + + let latest_terminal = runs + .iter() + .find(|run| { + matches!( + run.status, + AgentRunStatus::Success + | AgentRunStatus::Error + | AgentRunStatus::Canceled + | AgentRunStatus::Timeout + ) + }) + .map(|run| { + let title = derive_run_title(run); + let gate_key = derive_run_gate_key(run, title.as_str()); + ThemeWorkbenchRunTerminalItem { + run_id: run.id.clone(), + execution_id: derive_run_execution_id(run), + session_id: run.session_id.clone(), + artifact_paths: derive_run_artifact_paths(run), + title, + gate_key, + status: run.status.clone(), + source: run.source.clone(), + source_ref: run.source_ref.clone(), + started_at: run.started_at.clone(), + finished_at: run.finished_at.clone(), + } + }); + + Ok(ThemeWorkbenchRunState { + run_state, + current_gate_key, + queue_items, + latest_terminal, + updated_at: Utc::now().to_rfc3339(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_run_with_metadata(metadata: Option) -> AgentRun { + AgentRun { + id: "run-test-1".to_string(), + source: "skill".to_string(), + source_ref: Some("topic_research".to_string()), + session_id: Some("session-test".to_string()), + status: AgentRunStatus::Running, + started_at: "2026-03-06T00:00:00Z".to_string(), + finished_at: None, + duration_ms: None, + error_code: None, + error_message: None, + metadata: metadata.map(|raw| raw.to_string()), + created_at: "2026-03-06T00:00:00Z".to_string(), + updated_at: "2026-03-06T00:00:00Z".to_string(), + } + } + + #[test] + fn derive_run_gate_key_should_prefer_metadata_gate_key() { + let run = sample_run_with_metadata(Some(serde_json::json!({ + "gate_key": "publish_confirm" + }))); + let gate_key = derive_run_gate_key(&run, "任意标题"); + assert_eq!(gate_key, "publish_confirm"); + } + + #[test] + fn derive_run_gate_key_should_fallback_to_probe_inference() { + let run = sample_run_with_metadata(None); + let gate_key = derive_run_gate_key(&run, "执行选题调研"); + assert_eq!(gate_key, "topic_select"); + } + + #[test] + fn normalize_gate_key_should_reject_unknown_values() { + assert_eq!( + normalize_gate_key("write_mode"), + Some("write_mode".to_string()) + ); + assert!(normalize_gate_key("unknown_gate").is_none()); + } + + #[test] + fn derive_current_gate_key_should_prefer_running_item() { + let queue_items = vec![ + ThemeWorkbenchRunTodoItem { + run_id: "run-1".to_string(), + execution_id: None, + session_id: None, + artifact_paths: vec![], + title: "选题调研".to_string(), + gate_key: "topic_select".to_string(), + status: AgentRunStatus::Queued, + source: "skill".to_string(), + source_ref: None, + started_at: "2026-03-06T00:00:00Z".to_string(), + }, + ThemeWorkbenchRunTodoItem { + run_id: "run-2".to_string(), + execution_id: None, + session_id: None, + artifact_paths: vec![], + title: "写作中".to_string(), + gate_key: "write_mode".to_string(), + status: AgentRunStatus::Running, + source: "skill".to_string(), + source_ref: None, + started_at: "2026-03-06T00:00:01Z".to_string(), + }, + ]; + + assert_eq!( + derive_current_gate_key(queue_items.as_slice()), + "write_mode".to_string() + ); + } + + #[test] + fn derive_current_gate_key_should_fallback_to_first_item() { + let queue_items = vec![ThemeWorkbenchRunTodoItem { + run_id: "run-1".to_string(), + execution_id: None, + session_id: None, + artifact_paths: vec![], + title: "选题调研".to_string(), + gate_key: "topic_select".to_string(), + status: AgentRunStatus::Queued, + source: "skill".to_string(), + source_ref: None, + started_at: "2026-03-06T00:00:00Z".to_string(), + }]; + + assert_eq!( + derive_current_gate_key(queue_items.as_slice()), + "topic_select".to_string() + ); + } + + #[test] + fn derive_current_gate_key_should_return_idle_when_empty() { + assert_eq!(derive_current_gate_key(&[]), "idle".to_string()); + } + + #[test] + fn derive_run_execution_id_should_prefer_metadata_execution_id() { + let run = sample_run_with_metadata(Some(serde_json::json!({ + "execution_id": "exec-12345", + "version_id": "version-legacy", + }))); + assert_eq!( + derive_run_execution_id(&run), + Some("exec-12345".to_string()) + ); + } + + #[test] + fn derive_run_artifact_paths_should_parse_non_empty_paths() { + let run = sample_run_with_metadata(Some(serde_json::json!({ + "artifact_paths": [ + "social-posts/demo.md", + " ", + "social-posts/demo.cover.json" + ], + }))); + + assert_eq!( + derive_run_artifact_paths(&run), + vec![ + "social-posts/demo.md".to_string(), + "social-posts/demo.cover.json".to_string(), + ] + ); + } + + #[test] + fn collect_stale_run_ids_should_only_pick_expired_non_terminal_runs() { + let now = Utc::now(); + let stale_started_at = + (now - chrono::Duration::seconds(STALE_RUN_TIMEOUT_SECONDS + 20)).to_rfc3339(); + let fresh_started_at = (now - chrono::Duration::seconds(10)).to_rfc3339(); + + let stale_run = AgentRun { + id: "run-stale".to_string(), + source: "chat".to_string(), + source_ref: Some("aster_agent_chat_stream".to_string()), + session_id: Some("session-1".to_string()), + status: AgentRunStatus::Running, + started_at: stale_started_at.clone(), + finished_at: None, + duration_ms: None, + error_code: None, + error_message: None, + metadata: None, + created_at: stale_started_at.clone(), + updated_at: stale_started_at, + }; + let fresh_run = AgentRun { + id: "run-fresh".to_string(), + source: "chat".to_string(), + source_ref: Some("aster_agent_chat_stream".to_string()), + session_id: Some("session-1".to_string()), + status: AgentRunStatus::Queued, + started_at: fresh_started_at.clone(), + finished_at: None, + duration_ms: None, + error_code: None, + error_message: None, + metadata: None, + created_at: fresh_started_at.clone(), + updated_at: fresh_started_at, + }; + let terminal_run = AgentRun { + id: "run-terminal".to_string(), + source: "chat".to_string(), + source_ref: Some("aster_agent_chat_stream".to_string()), + session_id: Some("session-1".to_string()), + status: AgentRunStatus::Success, + started_at: now.to_rfc3339(), + finished_at: Some(now.to_rfc3339()), + duration_ms: Some(1200), + error_code: None, + error_message: None, + metadata: None, + created_at: now.to_rfc3339(), + updated_at: now.to_rfc3339(), + }; + + let stale_ids = collect_stale_run_ids(&[stale_run, fresh_run, terminal_run], now); + assert_eq!(stale_ids, vec!["run-stale".to_string()]); + } +} diff --git a/src-tauri/src/commands/image_upload_cmd.rs b/src-tauri/src/commands/image_upload_cmd.rs new file mode 100644 index 000000000..9d039acb8 --- /dev/null +++ b/src-tauri/src/commands/image_upload_cmd.rs @@ -0,0 +1,99 @@ +//! 图片上传 Tauri 命令 +//! +//! 提供图片上传到会话文件系统的功能。 + +use crate::commands::session_files_cmd::SessionFilesState; +use base64::{engine::general_purpose, Engine as _}; +use std::path::Path; +use tauri::State; + +/// 支持的图片格式 +const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"]; + +/// 图片文件最大大小(10MB) +const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; + +/// 验证文件是否为支持的图片格式 +fn is_supported_image(file_path: &str) -> bool { + let path = Path::new(file_path); + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + return SUPPORTED_IMAGE_EXTENSIONS.contains(&ext_str.as_str()); + } + false +} + +/// 上传图片到会话 +/// +/// # 参数 +/// - `session_id`: 会话ID +/// - `file_path`: 本地图片文件路径 +/// +/// # 返回 +/// 返回图片在会话中的访问路径 +#[tauri::command] +pub async fn upload_image_to_session( + state: State<'_, SessionFilesState>, + session_id: String, + file_path: String, +) -> Result { + // 验证文件格式 + if !is_supported_image(&file_path) { + return Err(format!( + "不支持的图片格式。支持的格式:{}", + SUPPORTED_IMAGE_EXTENSIONS.join(", ") + )); + } + + // 检查文件是否存在 + let path = Path::new(&file_path); + if !path.exists() { + return Err("文件不存在".to_string()); + } + + // 检查文件大小 + let metadata = std::fs::metadata(path).map_err(|e| format!("读取文件元数据失败: {}", e))?; + if metadata.len() > MAX_IMAGE_SIZE { + return Err(format!( + "图片文件过大(最大 {}MB)", + MAX_IMAGE_SIZE / 1024 / 1024 + )); + } + + // 读取文件内容 + let content = std::fs::read(path).map_err(|e| format!("读取文件失败: {}", e))?; + + // 生成文件名 + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or("无效的文件名")?; + + // 保存文件(使用 base64 编码存储二进制数据) + let base64_content = general_purpose::STANDARD.encode(&content); + + // 调用 session_files_cmd 的函数来保存文件 + super::session_files_cmd::session_files_save_file( + state.clone(), + session_id.clone(), + file_name.to_string(), + base64_content, + )?; + + // 返回文件访问路径 + super::session_files_cmd::session_files_resolve_file_path( + state, + session_id, + file_name.to_string(), + ) +} + +/// 从会话中读取图片(返回 base64 编码) +#[tauri::command] +pub fn read_image_from_session( + state: State, + session_id: String, + file_name: String, +) -> Result { + super::session_files_cmd::session_files_read_file(state, session_id, file_name) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4c07b7421..950858e76 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod connect_cmd; pub mod connection_cmd; pub mod content_cmd; pub mod context_memory; +pub mod document_import_cmd; pub mod ecommerce_review_reply_cmd; pub mod execution_run_cmd; pub mod external_tools_cmd; @@ -19,6 +20,7 @@ pub mod gateway_tunnel_cmd; pub mod general_chat_cmd; pub mod heartbeat_cmd; pub mod image_search_cmd; +pub mod image_upload_cmd; pub mod injection_cmd; pub mod kiro_local; pub mod machine_id_cmd; @@ -57,6 +59,7 @@ pub mod telegram_remote_cmd; pub mod telemetry_cmd; pub mod template_cmd; pub mod terminal_cmd; +pub mod theme_context_cmd; pub mod tool_hooks; pub mod tray_cmd; pub mod unified_chat_cmd; diff --git a/src-tauri/src/commands/session_files_cmd.rs b/src-tauri/src/commands/session_files_cmd.rs index a88340ec3..61f872502 100644 --- a/src-tauri/src/commands/session_files_cmd.rs +++ b/src-tauri/src/commands/session_files_cmd.rs @@ -112,6 +112,17 @@ pub fn session_files_read_file( storage.read_file(&session_id, &file_name) } +/// 解析会话文件绝对路径 +#[tauri::command] +pub fn session_files_resolve_file_path( + state: State, + session_id: String, + file_name: String, +) -> Result { + let storage = state.0.lock().map_err(|e| format!("锁定失败: {e}"))?; + storage.resolve_file_path(&session_id, &file_name) +} + /// 删除会话文件 #[tauri::command] pub fn session_files_delete_file( diff --git a/src-tauri/src/commands/skill_exec_cmd.rs b/src-tauri/src/commands/skill_exec_cmd.rs index 34444e978..2bae02b76 100644 --- a/src-tauri/src/commands/skill_exec_cmd.rs +++ b/src-tauri/src/commands/skill_exec_cmd.rs @@ -21,10 +21,15 @@ use tauri::{Emitter, State}; use uuid::Uuid; use aster::conversation::message::Message; +use chrono::Utc; use crate::agent::aster_state::SessionConfigBuilder; use crate::agent::{AsterAgentState, TauriAgentEvent}; -use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::commands::aster_agent_cmd::{ + ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, + ensure_social_image_tool_registered, +}; use crate::commands::skill_error::{ format_skill_error, map_find_skill_error, SKILL_ERR_CATALOG_UNAVAILABLE, SKILL_ERR_EXECUTE_FAILED, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED, @@ -35,7 +40,7 @@ use crate::database::DbConnection; use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource}; use crate::services::memory_profile_prompt_service::build_memory_profile_prompt; use crate::skills::TauriExecutionCallback; -use proxycast_agent::event_converter::convert_agent_event; +use proxycast_agent::event_converter::{convert_agent_event, TauriToolResult}; use proxycast_skills::{ find_skill_by_name, get_proxycast_skills_dir, load_skills_from_directory, ExecutionCallback, }; @@ -130,6 +135,393 @@ pub struct SkillExecutionResult { pub steps_completed: Vec, } +const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover"; +const SOCIAL_POST_OUTPUT_DIR: &str = "social-posts"; +const SOCIAL_POST_WRITE_TOOL_NAME: &str = "write_file"; +const SOCIAL_POST_EMPTY_FALLBACK_CONTENT: &str = "# 社媒文案\n\n(生成结果为空,请重试。)"; +const SOCIAL_POST_FALLBACK_COVER_URL: &str = "cover-generation-failed"; +const SOCIAL_POST_FALLBACK_COVER_NOTE: &str = "封面图生成失败,可稍后仅重试配图。"; +const SOCIAL_POST_DEFAULT_IMAGE_SIZE: &str = "1024x1024"; + +#[derive(Debug, Clone)] +struct SocialSkillOutputEnvelope { + final_output: String, + file_path: String, + file_content: String, +} + +fn infer_theme_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str { + let probe = format!("{} {}", skill_name, user_input).to_lowercase(); + if probe.contains("publish") + || probe.contains("adapt") + || probe.contains("distribution") + || probe.contains("release") + || probe.contains("发布") + || probe.contains("分发") + || probe.contains("平台适配") + { + return "publish_confirm"; + } + if probe.contains("topic") + || probe.contains("research") + || probe.contains("trend") + || probe.contains("idea") + || probe.contains("选题") + || probe.contains("方向") + || probe.contains("调研") + || probe.contains("洞察") + { + return "topic_select"; + } + "write_mode" +} + +fn normalize_social_post_output( + skill_name: &str, + user_input: &str, + execution_id: &str, + raw_output: &str, +) -> Option { + if skill_name != SOCIAL_POST_WITH_COVER_SKILL_NAME { + return None; + } + + let generated_path = build_social_post_file_path(user_input, execution_id); + if let Some((range, existing_path, content)) = extract_first_write_file_block(raw_output) { + let normalized_content = normalize_social_markdown_contract(&content); + let has_existing_path = existing_path.is_some(); + let path = existing_path.unwrap_or_else(|| generated_path.clone()); + + if has_existing_path { + if normalized_content != content { + let normalized_block = build_write_file_block(&path, &normalized_content); + let mut rebuilt = String::new(); + rebuilt.push_str(&raw_output[..range.start]); + rebuilt.push_str(&normalized_block); + rebuilt.push_str(&raw_output[range.end..]); + return Some(SocialSkillOutputEnvelope { + final_output: rebuilt, + file_path: path, + file_content: normalized_content, + }); + } + return Some(SocialSkillOutputEnvelope { + final_output: raw_output.to_string(), + file_path: path, + file_content: normalized_content, + }); + } + + let normalized_block = build_write_file_block(&path, &normalized_content); + let mut rebuilt = String::new(); + rebuilt.push_str(&raw_output[..range.start]); + rebuilt.push_str(&normalized_block); + rebuilt.push_str(&raw_output[range.end..]); + + return Some(SocialSkillOutputEnvelope { + final_output: rebuilt, + file_path: path, + file_content: normalized_content, + }); + } + + let normalized_content = normalize_social_markdown_contract(raw_output); + Some(SocialSkillOutputEnvelope { + final_output: build_write_file_block(&generated_path, &normalized_content), + file_path: generated_path, + file_content: normalized_content, + }) +} + +fn extract_first_write_file_block( + raw_output: &str, +) -> Option<(std::ops::Range, Option, String)> { + let open_start = raw_output.find("')?; + let open_end = open_start + open_end_offset; + let open_tag = &raw_output[open_start..=open_end]; + + let content_start = open_end + 1; + let close_tag = ""; + let close_offset = raw_output[content_start..].find(close_tag)?; + let close_start = content_start + close_offset; + let block_end = close_start + close_tag.len(); + + let content = raw_output[content_start..close_start].trim().to_string(); + let path = extract_write_file_path(open_tag); + Some((open_start..block_end, path, content)) +} + +fn extract_write_file_path(open_tag: &str) -> Option { + let path_idx = open_tag.find("path")?; + let after_path = &open_tag[path_idx + "path".len()..]; + let equal_idx = after_path.find('=')?; + let value = after_path[equal_idx + 1..].trim_start(); + let quote = value.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + + let rest = &value[quote.len_utf8()..]; + let end_idx = rest.find(quote)?; + let path = rest[..end_idx].trim(); + if path.is_empty() { + None + } else { + Some(path.to_string()) + } +} + +fn normalize_social_output_content(content: &str) -> String { + let trimmed = content.trim(); + if trimmed.is_empty() { + SOCIAL_POST_EMPTY_FALLBACK_CONTENT.to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_social_markdown_contract(content: &str) -> String { + let mut normalized = normalize_social_output_content(content); + if !normalized.contains("![封面图](") { + normalized = format!("{normalized}\n\n![封面图]({SOCIAL_POST_FALLBACK_COVER_URL})"); + } + + if !normalized.contains("## 配图说明") { + normalized.push_str("\n\n## 配图说明\n"); + normalized.push_str("- 提示词:未提供\n"); + normalized.push_str(&format!("- 尺寸:{SOCIAL_POST_DEFAULT_IMAGE_SIZE}\n")); + normalized.push_str("- 状态:失败\n"); + normalized.push_str(&format!("- 备注:{SOCIAL_POST_FALLBACK_COVER_NOTE}\n")); + } + + normalized +} + +fn extract_cover_url_from_markdown(content: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("![") { + continue; + } + let open = trimmed.find("](")?; + let close = trimmed.rfind(')')?; + if close <= open + 2 { + continue; + } + let url = trimmed[(open + 2)..close].trim(); + if !url.is_empty() { + return Some(url.to_string()); + } + } + None +} + +fn extract_detail_value(content: &str, label: &str) -> Option { + let probe = format!("- {label}:"); + for line in content.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix(&probe) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +fn derive_social_auxiliary_paths(article_path: &str) -> (String, String) { + let base = article_path.strip_suffix(".md").unwrap_or(article_path); + ( + format!("{base}.cover.json"), + format!("{base}.publish-pack.json"), + ) +} + +fn collect_social_artifact_paths_from_output(output: Option<&str>) -> Vec { + let Some(raw_output) = output else { + return Vec::new(); + }; + let Some((_, maybe_path, _)) = extract_first_write_file_block(raw_output) else { + return Vec::new(); + }; + let Some(article_path) = maybe_path else { + return Vec::new(); + }; + let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(&article_path); + vec![article_path, cover_meta_path, publish_pack_path] +} + +fn summarize_social_content(content: &str) -> String { + let compact = content + .lines() + .filter(|line| !line.trim().starts_with('#')) + .collect::>() + .join(" "); + let compact = compact.split_whitespace().collect::>().join(" "); + compact.chars().take(180).collect() +} + +fn build_social_auxiliary_file_payloads( + execution_id: &str, + user_input: &str, + article_path: &str, + article_content: &str, +) -> Vec<(String, String)> { + let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(article_path); + let cover_url = extract_cover_url_from_markdown(article_content) + .unwrap_or_else(|| SOCIAL_POST_FALLBACK_COVER_URL.to_string()); + let cover_prompt = + extract_detail_value(article_content, "提示词").unwrap_or_else(|| "未提供".to_string()); + let cover_size = extract_detail_value(article_content, "尺寸") + .unwrap_or_else(|| SOCIAL_POST_DEFAULT_IMAGE_SIZE.to_string()); + let cover_status = extract_detail_value(article_content, "状态").unwrap_or_else(|| { + if cover_url == SOCIAL_POST_FALLBACK_COVER_URL { + "失败".to_string() + } else { + "成功".to_string() + } + }); + let cover_remark = extract_detail_value(article_content, "备注").unwrap_or_else(|| { + if cover_status == "失败" { + SOCIAL_POST_FALLBACK_COVER_NOTE.to_string() + } else { + "".to_string() + } + }); + + let cover_meta = serde_json::json!({ + "execution_id": execution_id, + "article_path": article_path, + "cover_url": cover_url, + "prompt": cover_prompt, + "size": cover_size, + "status": cover_status, + "remark": cover_remark, + "generated_at": Utc::now().to_rfc3339(), + }); + + let publish_pack = serde_json::json!({ + "execution_id": execution_id, + "pipeline": ["topic_select", "write_mode", "publish_confirm"], + "article_path": article_path, + "cover_meta_path": cover_meta_path, + "source_input": user_input, + "recommended_channels": ["xiaohongshu", "wechat"], + "summary": summarize_social_content(article_content), + "generated_at": Utc::now().to_rfc3339(), + }); + + vec![ + ( + cover_meta_path, + serde_json::to_string_pretty(&cover_meta).unwrap_or_else(|_| cover_meta.to_string()), + ), + ( + publish_pack_path, + serde_json::to_string_pretty(&publish_pack) + .unwrap_or_else(|_| publish_pack.to_string()), + ), + ] +} + +fn build_write_file_block(file_path: &str, file_content: &str) -> String { + format!("\n{file_content}\n") +} + +fn build_social_post_file_path(user_input: &str, execution_id: &str) -> String { + let timestamp = Utc::now().format("%Y%m%d-%H%M%S"); + let slug = build_social_post_slug(user_input); + let suffix = build_execution_suffix(execution_id); + format!("{SOCIAL_POST_OUTPUT_DIR}/{timestamp}-{slug}-{suffix}.md") +} + +fn build_social_post_slug(user_input: &str) -> String { + let mut normalized = String::new(); + let mut last_was_dash = false; + + for ch in user_input.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + last_was_dash = false; + continue; + } + + if !last_was_dash { + normalized.push('-'); + last_was_dash = true; + } + } + + let trimmed = normalized.trim_matches('-'); + let truncated: String = trimmed.chars().take(24).collect(); + if truncated.is_empty() { + "post".to_string() + } else { + truncated + } +} + +fn build_execution_suffix(execution_id: &str) -> String { + let normalized: String = execution_id + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .take(6) + .collect(); + if normalized.is_empty() { + "run".to_string() + } else { + normalized.to_ascii_lowercase() + } +} + +fn build_social_tool_event_id(execution_id: &str, file_path: &str) -> String { + let mut hash: u32 = 0x811c9dc5; + for byte in file_path.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(0x01000193); + } + format!("social-write-{execution_id}-{hash:08x}") +} + +fn emit_social_write_file_events( + app_handle: &tauri::AppHandle, + execution_id: &str, + file_path: &str, + file_content: &str, +) { + let event_name = format!("skill-exec-{execution_id}"); + let tool_id = build_social_tool_event_id(execution_id, file_path); + let arguments = serde_json::json!({ + "path": file_path, + "content": file_content, + }) + .to_string(); + + let tool_start = TauriAgentEvent::ToolStart { + tool_name: SOCIAL_POST_WRITE_TOOL_NAME.to_string(), + tool_id: tool_id.clone(), + arguments: Some(arguments), + }; + if let Err(err) = app_handle.emit(&event_name, &tool_start) { + tracing::warn!("[execute_skill] 发送社媒写入工具开始事件失败: {}", err); + } + + let tool_end = TauriAgentEvent::ToolEnd { + tool_id, + result: TauriToolResult { + success: true, + output: format!("写入社媒文稿: {file_path}"), + error: None, + images: None, + }, + }; + if let Err(err) = app_handle.emit(&event_name, &tool_end) { + tracing::warn!("[execute_skill] 发送社媒写入工具完成事件失败: {}", err); + } +} + /// 执行 Skill /// /// 加载并执行指定的 Skill,使用 Aster Agent 系统提供完整的工具集支持。 @@ -157,6 +549,7 @@ pub struct SkillExecutionResult { pub async fn execute_skill( app_handle: tauri::AppHandle, db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, config_manager: State<'_, GlobalConfigManagerState>, aster_state: State<'_, AsterAgentState>, skill_name: String, @@ -169,6 +562,8 @@ pub async fn execute_skill( // 生成执行 ID,并优先复用前端会话 ID(提升 /skill 与主会话上下文一致性) let execution_id = execution_id.unwrap_or_else(|| Uuid::new_v4().to_string()); let session_id = session_id.unwrap_or_else(|| format!("skill-exec-{}", Uuid::new_v4())); + let inferred_gate_key = + infer_theme_workbench_gate_key(skill_name.as_str(), user_input.as_str()); let memory_profile_prompt = build_memory_profile_prompt(&config_manager.config()); let tracker = ExecutionTracker::new(db.inner().clone()); @@ -179,6 +574,8 @@ pub async fn execute_skill( Some(session_id.clone()), Some(serde_json::json!({ "execution_id": execution_id.clone(), + "skill_name": skill_name.clone(), + "gate_key": inferred_gate_key, "provider_override": provider_override.clone(), "model_override": model_override.clone(), })), @@ -225,6 +622,27 @@ pub async fn execute_skill( format!("注册浏览器工具失败: {e}"), ) })?; + ensure_social_image_tool_registered(aster_state.inner(), config_manager.inner()) + .await + .map_err(|e| { + format_skill_error( + SKILL_ERR_SESSION_INIT_FAILED, + format!("注册社媒生图工具失败: {e}"), + ) + })?; + ensure_creation_task_tools_registered( + aster_state.inner(), + db.inner(), + api_key_provider_service.inner(), + &app_handle, + ) + .await + .map_err(|e| { + format_skill_error( + SKILL_ERR_SESSION_INIT_FAILED, + format!("注册创作任务工具失败: {e}"), + ) + })?; // 4. 配置 Provider(从凭证池选择,支持 fallback) let preferred_provider = provider_override @@ -280,7 +698,7 @@ pub async fn execute_skill( } } - configure_result.map_err(|e| { + let configured_provider = configure_result.map_err(|e| { format_skill_error( SKILL_ERR_PROVIDER_UNAVAILABLE, format!( @@ -289,10 +707,15 @@ pub async fn execute_skill( ) })?; + let resolved_provider = configured_provider.provider_name.clone(); + let resolved_model = configured_provider.model_name.clone(); + tracing::info!( - "[execute_skill] Provider 配置成功: preferred={}, model={}", + "[execute_skill] Provider 配置成功: requested={} / {}, resolved={} / {}", preferred_provider, - preferred_model + preferred_model, + resolved_provider, + resolved_model ); // 5. 根据 execution_mode 分支执行 @@ -325,15 +748,42 @@ pub async fn execute_skill( } }, |result| match result { - Ok(exec_result) if exec_result.success => RunFinishDecision { - status: crate::database::dao::agent_run::AgentRunStatus::Success, - error_code: None, - error_message: None, - metadata: Some(serde_json::json!({ - "skill_name": skill_name, - "execution_id": execution_id, - })), - }, + Ok(exec_result) if exec_result.success => { + let artifact_paths = if skill_name == SOCIAL_POST_WITH_COVER_SKILL_NAME { + collect_social_artifact_paths_from_output(exec_result.output.as_deref()) + } else { + Vec::new() + }; + let metadata = if skill_name == SOCIAL_POST_WITH_COVER_SKILL_NAME { + serde_json::json!({ + "skill_name": skill_name, + "execution_id": execution_id, + "workflow": "social_content_pipeline_v1", + "version_id": execution_id, + "stages": ["topic_select", "write_mode", "publish_confirm"], + "artifact_paths": artifact_paths, + "provider_override": provider_override, + "model_override": model_override, + "requested_provider": provider_override, + "requested_model": model_override, + }) + } else { + serde_json::json!({ + "skill_name": skill_name, + "execution_id": execution_id, + "provider_override": provider_override, + "model_override": model_override, + "requested_provider": provider_override, + "requested_model": model_override, + }) + }; + RunFinishDecision { + status: crate::database::dao::agent_run::AgentRunStatus::Success, + error_code: None, + error_message: None, + metadata: Some(metadata), + } + } Ok(exec_result) => RunFinishDecision { status: crate::database::dao::agent_run::AgentRunStatus::Error, error_code: Some("skill_execute_failed".to_string()), @@ -342,6 +792,10 @@ pub async fn execute_skill( "skill_name": skill_name, "execution_id": execution_id, "success": false, + "provider_override": provider_override, + "model_override": model_override, + "requested_provider": provider_override, + "requested_model": model_override, })), }, Err(err) => RunFinishDecision { @@ -351,6 +805,10 @@ pub async fn execute_skill( metadata: Some(serde_json::json!({ "skill_name": skill_name, "execution_id": execution_id, + "provider_override": provider_override, + "model_override": model_override, + "requested_provider": provider_override, + "requested_model": model_override, })), }, }, @@ -464,18 +922,49 @@ async fn execute_skill_prompt( }], }) } else { - callback.on_step_complete("main", &final_output); - callback.on_complete(true, Some(&final_output), None); + let normalized_output = normalize_social_post_output( + &skill.skill_name, + user_input, + execution_id, + &final_output, + ); + let output_for_return = if let Some(ref social_output) = normalized_output { + emit_social_write_file_events( + app_handle, + execution_id, + &social_output.file_path, + &social_output.file_content, + ); + for (artifact_path, artifact_content) in build_social_auxiliary_file_payloads( + execution_id, + user_input, + &social_output.file_path, + &social_output.file_content, + ) { + emit_social_write_file_events( + app_handle, + execution_id, + &artifact_path, + &artifact_content, + ); + } + social_output.final_output.clone() + } else { + final_output.clone() + }; + + callback.on_step_complete("main", &output_for_return); + callback.on_complete(true, Some(&output_for_return), None); Ok(SkillExecutionResult { success: true, - output: Some(final_output.clone()), + output: Some(output_for_return.clone()), error: None, steps_completed: vec![StepResult { step_id: "main".to_string(), step_name: skill.display_name.clone(), success: true, - output: Some(final_output), + output: Some(output_for_return), error: None, }], }) @@ -890,6 +1379,107 @@ Body assert_eq!(fm.description, Some("single quoted".to_string())); } + #[test] + fn test_normalize_social_post_output_wraps_plain_markdown() { + let normalized = normalize_social_post_output( + SOCIAL_POST_WITH_COVER_SKILL_NAME, + "春季上新", + "exec123456", + "# 标题\n\n正文内容", + ) + .expect("should normalize"); + + assert!(normalized + .final_output + .contains("\n# 标题\n\n正文\n"; + let normalized = normalize_social_post_output( + SOCIAL_POST_WITH_COVER_SKILL_NAME, + "春季上新", + "exec123456", + raw_output, + ) + .expect("should normalize"); + + assert_eq!(normalized.file_path, "social-posts/custom-post.md"); + assert!(normalized + .final_output + .contains("social-posts/custom-post.md")); + assert!(normalized.file_content.contains("# 标题")); + assert!(normalized.file_content.contains("![封面图](")); + assert!(normalized.file_content.contains("## 配图说明")); + } + + #[test] + fn test_normalize_social_post_output_injects_missing_path() { + let raw_output = "前置说明\n\n# 标题\n\n正文\n\n后置说明"; + let normalized = normalize_social_post_output( + SOCIAL_POST_WITH_COVER_SKILL_NAME, + "spring launch", + "exec123456", + raw_output, + ) + .expect("should normalize"); + + assert!(normalized.final_output.contains("前置说明")); + assert!(normalized.final_output.contains("后置说明")); + assert!(normalized + .final_output + .contains("\n# 标题\n\n正文\n"; + let paths = collect_social_artifact_paths_from_output(Some(output)); + assert_eq!(paths.len(), 3); + assert_eq!(paths[0], "social-posts/demo.md"); + assert!(paths[1].ends_with(".cover.json")); + assert!(paths[2].ends_with(".publish-pack.json")); + } + + #[test] + fn test_build_social_post_slug_fallback_to_post() { + assert_eq!(build_social_post_slug(""), "post"); + assert_eq!(build_social_post_slug("!!!"), "post"); + assert_eq!( + build_social_post_slug("Spring Launch 2026"), + "spring-launch-2026" + ); + } + #[test] fn test_parse_allowed_tools() { assert_eq!(parse_allowed_tools(None), None); @@ -1014,4 +1604,26 @@ Content 2 let skills = load_skills_from_directory(std::path::Path::new("/nonexistent/path")); assert!(skills.is_empty()); } + + #[test] + fn test_bundled_social_post_with_cover_skill_contract() { + let skill_file = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources/default-skills/social_post_with_cover/SKILL.md"); + + assert!(skill_file.exists()); + let content = std::fs::read_to_string(&skill_file).unwrap(); + let skill = load_skill_from_file("social_post_with_cover", &skill_file).unwrap(); + + assert_eq!(skill.skill_name, "social_post_with_cover"); + assert_eq!(skill.execution_mode, "prompt"); + assert_eq!( + skill.allowed_tools, + Some(vec![ + "social_generate_cover_image".to_string(), + "search_query".to_string(), + ]) + ); + assert!(content.contains(", + #[serde(alias = "providerType", alias = "providerId")] + pub provider_type: String, + pub model: String, + pub query: String, + pub mode: ThemeContextSearchMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ThemeContextSearchCitation { + pub title: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ThemeContextSearchResponse { + pub title: String, + pub summary: String, + pub citations: Vec, + pub raw_response: String, + pub attempts_summary: String, +} + +#[derive(Debug, Clone)] +struct ParsedThemeContextSearchPayload { + title: Option, + summary: Option, + citations: Vec, +} + +fn normalize_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn build_context_search_prompt(query: &str, mode: ThemeContextSearchMode) -> String { + let social_constraint = match mode { + ThemeContextSearchMode::Social => [ + "优先寻找社交媒体平台、品牌官方账号、媒体社媒账号、KOL/KOC 讨论与趋势帖相关信息。", + "如果直接社媒来源不足,可补充官方网站或媒体报道,但摘要必须保留社媒传播视角。", + "适当优先关注小红书、微博、公众号、抖音、B站、知乎等中文平台。", + ] + .join("\n"), + ThemeContextSearchMode::Web => { + "优先提供最新且可信的公开网络资料,兼顾官方来源与主流媒体。".to_string() + } + }; + + [ + "你是 ProxyCast 的资料检索助手。", + "请先执行联网搜索,再输出整理结果。", + "你必须返回且仅返回一个 JSON 对象,不要使用 Markdown 代码块,不要输出多余说明。", + "JSON 结构如下:", + r#"{"title":"","summary":"","citations":[{"title":"","url":""}]}"#, + "字段要求:", + "1. title:12-28 字中文标题,概括本次检索主题。", + "2. summary:180-320 字中文摘要,聚合 3-5 个来源,突出时间点、关键事实、趋势或洞察。", + "3. citations:保留 3-5 条最重要来源,必须带可访问 URL。", + social_constraint.as_str(), + &format!("检索主题:{}", query.trim()), + ] + .join("\n") +} + +fn strip_code_fence(value: &str) -> String { + value + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim() + .to_string() +} + +fn build_citation_title_from_url(url: &str) -> String { + Url::parse(url) + .ok() + .and_then(|parsed| { + parsed + .host_str() + .map(|host| host.trim_start_matches("www.").to_string()) + }) + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "来源链接".to_string()) +} + +fn sanitize_url(url: &str) -> String { + url.trim_end_matches(&[',', ')', '.', ';', '!', '?'][..]) + .trim() + .to_string() +} + +fn parse_json_payload(raw_response: &str) -> Option { + let trimmed = raw_response.trim(); + if trimmed.is_empty() { + return None; + } + + let fenced_match = regex::Regex::new(r"```(?:json)?\s*([\s\S]*?)\s*```") + .ok() + .and_then(|regex| regex.captures(trimmed)) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())); + let json_block_match = regex::Regex::new(r"\{[\s\S]*\}") + .ok() + .and_then(|regex| regex.find(trimmed)) + .map(|value| value.as_str().to_string()); + + let mut candidates = Vec::new(); + if let Some(value) = fenced_match { + candidates.push(value); + } + if let Some(value) = json_block_match { + candidates.push(value); + } + candidates.push(trimmed.to_string()); + + for candidate in candidates { + let normalized = strip_code_fence(&candidate); + let parsed = match serde_json::from_str::(&normalized) { + Ok(value) => value, + Err(_) => continue, + }; + + let citations_raw = parsed + .get("citations") + .and_then(serde_json::Value::as_array) + .or_else(|| parsed.get("sources").and_then(serde_json::Value::as_array)); + + let citations = citations_raw + .into_iter() + .flatten() + .filter_map(|item| { + let url = item + .get("url") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let title = item + .get("title") + .and_then(serde_json::Value::as_str) + .or_else(|| item.get("name").and_then(serde_json::Value::as_str)) + .map(normalize_whitespace) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| build_citation_title_from_url(url)); + Some(ThemeContextSearchCitation { + title, + url: url.to_string(), + }) + }) + .take(5) + .collect::>(); + + let title = parsed + .get("title") + .and_then(serde_json::Value::as_str) + .map(normalize_whitespace) + .filter(|value| !value.is_empty()); + let summary = parsed + .get("summary") + .and_then(serde_json::Value::as_str) + .or_else(|| parsed.get("content").and_then(serde_json::Value::as_str)) + .map(normalize_whitespace) + .filter(|value| !value.is_empty()); + + return Some(ParsedThemeContextSearchPayload { + title, + summary, + citations, + }); + } + + None +} + +fn extract_citations_from_text(raw_response: &str) -> Vec { + let mut citations = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + if let Ok(markdown_regex) = regex::Regex::new(r"\[([^\]]+)\]\((https?://[^\s)]+)\)") { + for captures in markdown_regex.captures_iter(raw_response) { + let url = captures + .get(2) + .map(|value| sanitize_url(value.as_str())) + .unwrap_or_default(); + if url.is_empty() || !seen.insert(url.clone()) { + continue; + } + let title = captures + .get(1) + .map(|value| normalize_whitespace(value.as_str())) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| build_citation_title_from_url(&url)); + citations.push(ThemeContextSearchCitation { title, url }); + if citations.len() >= 5 { + return citations; + } + } + } + + if let Ok(url_regex) = regex::Regex::new(r"https?://[^\s)\]]+") { + for captures in url_regex.find_iter(raw_response) { + let url = sanitize_url(captures.as_str()); + if url.is_empty() || !seen.insert(url.clone()) { + continue; + } + citations.push(ThemeContextSearchCitation { + title: build_citation_title_from_url(&url), + url, + }); + if citations.len() >= 5 { + break; + } + } + } + + citations +} + +fn build_fallback_summary(raw_response: &str) -> String { + let without_citations = regex::Regex::new(r#""citations"\s*:\s*\[[\s\S]*?\]"#) + .ok() + .map(|regex| { + regex + .replace_all(&strip_code_fence(raw_response), "") + .to_string() + }) + .unwrap_or_else(|| strip_code_fence(raw_response)); + let normalized = + normalize_whitespace(&without_citations.replace(&['{', '}', '[', ']', '"'][..], " ")); + + if normalized.is_empty() { + return "暂无可用摘要,请重新尝试检索。".to_string(); + } + + if normalized.chars().count() <= FALLBACK_SUMMARY_LENGTH { + return normalized; + } + + let mut summary = normalized + .chars() + .take(FALLBACK_SUMMARY_LENGTH) + .collect::(); + summary.push_str("..."); + summary +} + +fn build_fallback_title(query: &str, mode: ThemeContextSearchMode) -> String { + let suffix = match mode { + ThemeContextSearchMode::Social => "社媒搜索上下文", + ThemeContextSearchMode::Web => "网络搜索上下文", + }; + format!("{} · {}", query.trim(), suffix) +} + +fn normalize_search_result( + raw_response: &str, + query: &str, + mode: ThemeContextSearchMode, + attempts_summary: String, +) -> ThemeContextSearchResponse { + let parsed = parse_json_payload(raw_response); + let citations = parsed + .as_ref() + .filter(|payload| !payload.citations.is_empty()) + .map(|payload| payload.citations.clone()) + .unwrap_or_else(|| extract_citations_from_text(raw_response)); + + ThemeContextSearchResponse { + title: parsed + .as_ref() + .and_then(|payload| payload.title.clone()) + .unwrap_or_else(|| build_fallback_title(query, mode)), + summary: parsed + .as_ref() + .and_then(|payload| payload.summary.clone()) + .unwrap_or_else(|| build_fallback_summary(raw_response)), + citations, + raw_response: raw_response.to_string(), + attempts_summary, + } +} + +#[tauri::command] +pub async fn aster_agent_theme_context_search( + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + config_manager: State<'_, GlobalConfigManagerState>, + request: ThemeContextSearchRequest, +) -> Result { + let workspace_id = request.workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err("workspace_id 必填,请先选择项目工作区".to_string()); + } + + let provider_type = request.provider_type.trim().to_string(); + if provider_type.is_empty() { + return Err("当前未选择可用模型,无法执行联网搜索".to_string()); + } + + let model = request.model.trim().to_string(); + if model.is_empty() { + return Err("当前未选择可用模型,无法执行联网搜索".to_string()); + } + + let query = request.query.trim().to_string(); + if query.is_empty() { + return Err("搜索词不能为空".to_string()); + } + + if !state.is_initialized().await { + state.init_agent_with_db(&db).await?; + } + + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager + .get(&workspace_id) + .map_err(|error| format!("读取 workspace 失败: {error}"))? + .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; + let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; + let workspace_root = ensured.root_path.to_string_lossy().to_string(); + + let runtime_config = config_manager.config(); + apply_web_search_runtime_env(&runtime_config); + + let project_prompt = request + .project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|project_id| { + match AsterAgentState::build_project_system_prompt(&db, project_id) { + Ok(prompt) => Some(prompt), + Err(error) => { + tracing::warn!( + "[ThemeContextSearch] 加载项目上下文失败,降级为基础搜索提示词: {}", + error + ); + None + } + } + }); + + let request_tool_policy = resolve_request_tool_policy(Some(true), false); + let system_prompt = proxycast_agent::merge_system_prompt_with_request_tool_policy( + merge_system_prompt_with_web_search( + merge_system_prompt_with_memory_profile(project_prompt, &runtime_config), + &runtime_config, + ), + &request_tool_policy, + ); + + let session_id = format!("{}-{}", CONTEXT_SEARCH_SESSION_PREFIX, Uuid::new_v4()); + state + .configure_provider_from_pool(&db, &provider_type, &model, &session_id) + .await?; + + let cancel_token = state.create_cancel_token(&session_id).await; + let execution_result = { + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .as_ref() + .ok_or_else(|| "Agent not initialized".to_string())?; + + let mut session_config_builder = SessionConfigBuilder::new(&session_id); + session_config_builder = session_config_builder.include_context_trace(false); + if let Some(prompt) = system_prompt { + session_config_builder = session_config_builder.system_prompt(prompt); + } + + stream_reply_with_policy( + agent, + &build_context_search_prompt(&query, request.mode), + Some(Path::new(&workspace_root)), + session_config_builder.build(), + Some(cancel_token.clone()), + &request_tool_policy, + |_| {}, + ) + .await + }; + + state.remove_cancel_token(&session_id).await; + if let Err(error) = AsterAgentWrapper::delete_session_sync(&db, &session_id) { + tracing::warn!( + "[ThemeContextSearch] 删除临时会话失败: session={}, error={}", + session_id, + error + ); + } + + let execution = execution_result.map_err(|error| error.message)?; + let raw_response = execution.text_output.trim().to_string(); + if raw_response.is_empty() { + return Err("上下文搜索未返回可用内容,请重试".to_string()); + } + + Ok(normalize_search_result( + &raw_response, + &query, + request.mode, + execution.attempts_summary, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_parse_json_result() { + let result = normalize_search_result( + r#"{"title":"智能体市场观察","summary":"市场讨论聚焦推理成本、工作流平台和企业落地节奏。","citations":[{"title":"官方博客","url":"https://example.com/blog"}]}"#, + "智能体市场 2026", + ThemeContextSearchMode::Web, + "WebSearch#1:success".to_string(), + ); + + assert_eq!(result.title, "智能体市场观察"); + assert!(result.summary.contains("推理成本")); + assert_eq!( + result.citations, + vec![ThemeContextSearchCitation { + title: "官方博客".to_string(), + url: "https://example.com/blog".to_string(), + }] + ); + assert_eq!(result.attempts_summary, "WebSearch#1:success"); + } + + #[test] + fn should_fallback_to_text_and_links_when_json_invalid() { + let result = normalize_search_result( + [ + "2026 年社交媒体讨论聚焦 Agent 产品的真实 ROI。", + "参考链接:", + "[小红书热议](https://example.com/xhs)", + "https://example.com/weibo", + ] + .join("\n") + .as_str(), + "Agent 社媒讨论", + ThemeContextSearchMode::Social, + "WebSearch#1:success".to_string(), + ); + + assert!(result.title.contains("Agent 社媒讨论")); + assert!(result.summary.contains("真实 ROI")); + assert_eq!( + result.citations, + vec![ + ThemeContextSearchCitation { + title: "小红书热议".to_string(), + url: "https://example.com/xhs".to_string(), + }, + ThemeContextSearchCitation { + title: "example.com".to_string(), + url: "https://example.com/weibo".to_string(), + }, + ] + ); + } + + #[test] + fn should_deserialize_theme_context_request_with_aliases() { + let request: ThemeContextSearchRequest = serde_json::from_str( + r#"{ + "workspaceId": "workspace-test", + "projectId": "project-test", + "providerType": "openai", + "model": "gpt-4.1", + "query": "AI Agent 最新动态", + "mode": "web" + }"#, + ) + .expect("request should deserialize"); + + assert_eq!(request.workspace_id, "workspace-test"); + assert_eq!(request.project_id.as_deref(), Some("project-test")); + assert_eq!(request.provider_type, "openai"); + assert_eq!(request.model, "gpt-4.1"); + assert_eq!(request.mode, ThemeContextSearchMode::Web); + } +} diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index 5d36d577d..08dbcd1af 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -69,6 +69,7 @@ pub struct WorkspaceListItem { pub workspace_type: String, pub root_path: String, pub is_default: bool, + pub settings: WorkspaceSettings, pub created_at: i64, pub updated_at: i64, pub icon: Option, @@ -100,6 +101,7 @@ impl From for WorkspaceListItem { workspace_type: ws.workspace_type.as_str().to_string(), root_path: ws.root_path.to_string_lossy().to_string(), is_default: ws.is_default, + settings: ws.settings, created_at: ws.created_at.timestamp_millis(), updated_at: ws.updated_at.timestamp_millis(), icon: ws.icon, diff --git a/src-tauri/src/services/execution_tracker_service.rs b/src-tauri/src/services/execution_tracker_service.rs index 3b3d17537..e7f6127f9 100644 --- a/src-tauri/src/services/execution_tracker_service.rs +++ b/src-tauri/src/services/execution_tracker_service.rs @@ -196,6 +196,16 @@ impl ExecutionTracker { AgentRunDao::get_run(&conn, id).map_err(|e| format!("查询执行记录失败: {e}")) } + pub fn list_runs_by_session( + &self, + session_id: &str, + limit: usize, + ) -> Result, String> { + let conn = self.db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AgentRunDao::list_runs_by_session(&conn, session_id, limit) + .map_err(|e| format!("查询会话执行记录失败: {e}")) + } + pub async fn with_run( &self, source: RunSource, diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs new file mode 100644 index 000000000..249b4bf54 --- /dev/null +++ b/src-tauri/src/skills/default_skills.rs @@ -0,0 +1,160 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const VIDEO_GENERATE_SKILL_NAME: &str = "video_generate"; +const VIDEO_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/video_generate/SKILL.md"); + +const BROADCAST_GENERATE_SKILL_NAME: &str = "broadcast_generate"; +const BROADCAST_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/broadcast_generate/SKILL.md"); + +const COVER_GENERATE_SKILL_NAME: &str = "cover_generate"; +const COVER_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/cover_generate/SKILL.md"); + +const MODAL_RESOURCE_SEARCH_SKILL_NAME: &str = "modal_resource_search"; +const MODAL_RESOURCE_SEARCH_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/modal_resource_search/SKILL.md"); + +const IMAGE_GENERATE_SKILL_NAME: &str = "image_generate"; +const IMAGE_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/image_generate/SKILL.md"); + +const LIBRARY_SKILL_NAME: &str = "library"; +const LIBRARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/library/SKILL.md"); + +const URL_PARSE_SKILL_NAME: &str = "url_parse"; +const URL_PARSE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/url_parse/SKILL.md"); + +const RESEARCH_SKILL_NAME: &str = "research"; +const RESEARCH_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/research/SKILL.md"); + +const TYPESETTING_SKILL_NAME: &str = "typesetting"; +const TYPESETTING_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/typesetting/SKILL.md"); + +const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover"; +const SOCIAL_POST_WITH_COVER_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/social_post_with_cover/SKILL.md"); + +fn default_skills() -> [(&'static str, &'static str); 10] { + [ + (VIDEO_GENERATE_SKILL_NAME, VIDEO_GENERATE_SKILL_CONTENT), + ( + BROADCAST_GENERATE_SKILL_NAME, + BROADCAST_GENERATE_SKILL_CONTENT, + ), + (COVER_GENERATE_SKILL_NAME, COVER_GENERATE_SKILL_CONTENT), + ( + MODAL_RESOURCE_SEARCH_SKILL_NAME, + MODAL_RESOURCE_SEARCH_SKILL_CONTENT, + ), + (IMAGE_GENERATE_SKILL_NAME, IMAGE_GENERATE_SKILL_CONTENT), + (LIBRARY_SKILL_NAME, LIBRARY_SKILL_CONTENT), + (URL_PARSE_SKILL_NAME, URL_PARSE_SKILL_CONTENT), + (RESEARCH_SKILL_NAME, RESEARCH_SKILL_CONTENT), + (TYPESETTING_SKILL_NAME, TYPESETTING_SKILL_CONTENT), + ( + SOCIAL_POST_WITH_COVER_SKILL_NAME, + SOCIAL_POST_WITH_COVER_SKILL_CONTENT, + ), + ] +} + +fn skills_root_from_home(home_dir: &Path) -> PathBuf { + home_dir.join(".proxycast").join("skills") +} + +fn ensure_default_local_skills_in_home(home_dir: &Path) -> Result, String> { + let skills_root = skills_root_from_home(home_dir); + fs::create_dir_all(&skills_root) + .map_err(|e| format!("创建技能目录失败 {}: {e}", skills_root.display()))?; + + let mut installed = Vec::new(); + for (skill_name, skill_content) in default_skills() { + let skill_dir = skills_root.join(skill_name); + let skill_md_path = skill_dir.join("SKILL.md"); + if skill_md_path.exists() { + continue; + } + + fs::create_dir_all(&skill_dir) + .map_err(|e| format!("创建默认技能目录失败 {}: {e}", skill_dir.display()))?; + fs::write(&skill_md_path, skill_content) + .map_err(|e| format!("写入默认技能失败 {}: {e}", skill_md_path.display()))?; + installed.push(skill_name.to_string()); + } + Ok(installed) +} + +pub fn ensure_default_local_skills() -> Result, String> { + let home_dir = dirs::home_dir().ok_or_else(|| "无法获取用户 Home 目录".to_string())?; + ensure_default_local_skills_in_home(&home_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_install_default_skill_when_missing() { + let temp = tempfile::tempdir().expect("create temp dir"); + let installed = ensure_default_local_skills_in_home(temp.path()).expect("install"); + assert!(installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string())); + + let skill_md_path = temp + .path() + .join(".proxycast") + .join("skills") + .join(SOCIAL_POST_WITH_COVER_SKILL_NAME) + .join("SKILL.md"); + assert!(skill_md_path.exists()); + } + + #[test] + fn should_not_overwrite_existing_skill() { + let temp = tempfile::tempdir().expect("create temp dir"); + let skill_dir = temp + .path() + .join(".proxycast") + .join("skills") + .join(SOCIAL_POST_WITH_COVER_SKILL_NAME); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + let skill_md_path = skill_dir.join("SKILL.md"); + let existing_content = "custom skill content"; + fs::write(&skill_md_path, existing_content).expect("write custom skill"); + + let installed = ensure_default_local_skills_in_home(temp.path()).expect("install"); + assert!( + !installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()), + "已存在的 skill 不应被重新安装" + ); + + let current_content = fs::read_to_string(&skill_md_path).expect("read skill"); + assert_eq!(current_content, existing_content); + } + + #[test] + fn should_embed_social_image_tool_contract_in_default_skill() { + assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT + .contains("allowed-tools: social_generate_cover_image, search_query")); + assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("## 配图说明")); + assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("状态:{成功/失败}")); + } + + #[test] + fn should_embed_core_default_skills() { + assert!(VIDEO_GENERATE_SKILL_CONTENT.contains("name: video_generate")); + assert!(BROADCAST_GENERATE_SKILL_CONTENT.contains("name: broadcast_generate")); + assert!(COVER_GENERATE_SKILL_CONTENT.contains("name: cover_generate")); + assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("name: modal_resource_search")); + assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("name: image_generate")); + assert!(LIBRARY_SKILL_CONTENT.contains("name: library")); + assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse")); + assert!(RESEARCH_SKILL_CONTENT.contains("name: research")); + assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting")); + } +} diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index 278340d65..616b2237d 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -3,10 +3,12 @@ //! 纯逻辑已迁移到 `proxycast-skills` crate, //! 本模块保留 Tauri 相关实现和兼容导出层。 +mod default_skills; mod execution_callback; mod llm_provider; // Tauri 实现(留在主 crate) +pub use default_skills::ensure_default_local_skills; pub use execution_callback::TauriExecutionCallback; // 兼容导出(实际实现位于 proxycast-skills crate) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 92c243ad7..781c71bd5 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": "ProxyCast", - "version": "0.80.0", + "version": "0.81.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 308d7b056..727983e61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,6 @@ import { WorkbenchPage } from "./components/workspace"; import { ProjectType, createProject, - createContent, isUserProjectType, resolveProjectRootPath, } from "./lib/api/project"; @@ -283,15 +282,12 @@ function AppContent() { }); if (pendingRecommendation) { - const content = await createContent({ - project_id: project.id, - title: name, - body: pendingRecommendation.fullPrompt, - }); - - handleNavigate("agent", { + handleNavigate(getThemeWorkspacePage(type as WorkspaceTheme), { projectId: project.id, - contentId: content.id, + workspaceViewMode: "workspace", + workspaceCreatePrompt: pendingRecommendation.fullPrompt, + workspaceCreateSource: "workspace_prompt", + workspaceCreateFallbackTitle: name, }); setPendingRecommendation(null); @@ -368,30 +364,38 @@ function AppContent() { }, []); const renderThemeWorkspaces = () => { - return THEME_WORKSPACE_PAGES.map((page) => { - const theme = getThemeByWorkspacePage(page); + if (!THEME_WORKSPACE_PAGES.includes(currentPage as ThemeWorkspacePage)) { + return null; + } - return ( -
- -
- ); - }); + const page = currentPage as ThemeWorkspacePage; + const theme = getThemeByWorkspacePage(page); + + return ( +
+ +
+ ); }; const renderAllPages = () => { @@ -427,17 +431,19 @@ function AppContent() { flexDirection: "column", }} > - + {currentPage === "agent" ? ( + + ) : null} {renderThemeWorkspaces()} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index cead4e0af..45f0c811e 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -609,7 +609,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { ? buildWorkspaceResetParams( item.params as AgentPageParams | undefined, (item.params as AgentPageParams | undefined)?.workspaceViewMode ?? - "workspace", + "project-management", ) : item.params; diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx new file mode 100644 index 000000000..096b8bbdf --- /dev/null +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatSidebar } from "./ChatSidebar"; +import type { Topic } from "../hooks/useAgentChat"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +const defaultTopics: Topic[] = [ + { + id: "topic-1", + title: "话题一", + createdAt: new Date(), + messagesCount: 2, + }, +]; + +function renderSidebar( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: React.ComponentProps = { + onNewChat: vi.fn(), + topics: defaultTopics, + currentTopicId: "topic-1", + onSwitchTopic: vi.fn(), + onDeleteTopic: vi.fn(), + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("ChatSidebar", () => { + it("应显示新建话题入口和话题列表", () => { + const container = renderSidebar(); + expect(container.textContent).toContain("新建话题"); + expect(container.textContent).toContain("话题一"); + }); + + it("点击话题时应触发切换", () => { + const onSwitchTopic = vi.fn(); + const container = renderSidebar({ onSwitchTopic }); + const topicItem = Array.from(container.querySelectorAll("span")).find( + (element) => element.textContent === "话题一", + ); + expect(topicItem).toBeTruthy(); + if (topicItem) { + act(() => { + topicItem.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + expect(onSwitchTopic).toHaveBeenCalledWith("topic-1"); + }); + + it("点击删除按钮时应触发删除", () => { + const onDeleteTopic = vi.fn(); + const container = renderSidebar({ onDeleteTopic }); + const deleteButton = container.querySelector( + "button.delete-btn", + ) as HTMLButtonElement | null; + expect(deleteButton).toBeTruthy(); + if (deleteButton) { + act(() => { + deleteButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + expect(onDeleteTopic).toHaveBeenCalledWith("topic-1"); + }); +}); diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index e83ab425f..51ea0c503 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -1,9 +1,5 @@ import React, { useState } from "react"; -import { - Plus, - MessageSquare, - Trash2, -} from "lucide-react"; +import { MessageSquare, Plus, Trash2 } from "lucide-react"; import styled from "styled-components"; import type { Topic } from "../hooks/useAgentChat"; @@ -17,7 +13,8 @@ const SidebarContainer = styled.div` `; const Toolbar = styled.div` - padding: 12px 12px 8px; + padding: 12px; + border-bottom: 1px solid hsl(var(--border)); `; const NewTopicButton = styled.button` @@ -43,7 +40,7 @@ const NewTopicButton = styled.button` const ListContainer = styled.div` flex: 1; overflow-y: auto; - padding: 0 8px 8px; + padding: 8px; `; const ListItem = styled.div<{ $active: boolean }>` @@ -78,11 +75,11 @@ const ListItem = styled.div<{ $active: boolean }>` transition: opacity 0.15s; padding: 4px; border-radius: 4px; + } - &:hover { - background-color: hsl(var(--destructive) / 0.15); - color: hsl(var(--destructive)); - } + .delete-btn:hover { + background-color: hsl(var(--destructive) / 0.15); + color: hsl(var(--destructive)); } &:hover .delete-btn { @@ -118,23 +115,21 @@ export const ChatSidebar: React.FC = ({ const [editTitle, setEditTitle] = useState(""); const editInputRef = React.useRef(null); - const handleDeleteClick = (e: React.MouseEvent, topicId: string) => { - e.stopPropagation(); + const handleDeleteClick = (event: React.MouseEvent, topicId: string) => { + event.stopPropagation(); onDeleteTopic(topicId); }; - // 开始编辑标题 const handleStartEdit = ( - e: React.MouseEvent, + event: React.MouseEvent, topicId: string, currentTitle: string, ) => { - e.stopPropagation(); + event.stopPropagation(); setEditingTopicId(topicId); setEditTitle(currentTitle); }; - // 保存编辑的标题 const handleSaveEdit = () => { if (editingTopicId && editTitle.trim() && onRenameTopic) { onRenameTopic(editingTopicId, editTitle.trim()); @@ -143,22 +138,19 @@ export const ChatSidebar: React.FC = ({ setEditTitle(""); }; - // 取消编辑 const handleCancelEdit = () => { setEditingTopicId(null); setEditTitle(""); }; - // 处理输入框键盘事件 - const handleEditKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + const handleEditKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { handleSaveEdit(); - } else if (e.key === "Escape") { + } else if (event.key === "Escape") { handleCancelEdit(); } }; - // 当编辑状态变化时,自动聚焦输入框 React.useEffect(() => { if (editingTopicId && editInputRef.current) { editInputRef.current.focus(); @@ -188,16 +180,14 @@ export const ChatSidebar: React.FC = ({ onSwitchTopic(topic.id); } }} - onDoubleClick={(e) => - handleStartEdit(e, topic.id, topic.title) + onDoubleClick={(event) => + handleStartEdit(event, topic.id, topic.title) } > {editingTopicId === topic.id ? ( @@ -205,10 +195,10 @@ export const ChatSidebar: React.FC = ({ ref={editInputRef} type="text" value={editTitle} - onChange={(e) => setEditTitle(e.target.value)} + onChange={(event) => setEditTitle(event.target.value)} onKeyDown={handleEditKeyDown} onBlur={handleSaveEdit} - onClick={(e) => e.stopPropagation()} + onClick={(event) => event.stopPropagation()} style={{ flex: 1, fontSize: "13px", @@ -221,14 +211,14 @@ export const ChatSidebar: React.FC = ({ ) : ( {topic.title} )} - {editingTopicId !== topic.id && ( + {editingTopicId !== topic.id ? ( - )} + ) : null} )) )} @@ -236,3 +226,4 @@ export const ChatSidebar: React.FC = ({ ); }; + diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index af66fa457..1ecdfa220 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -5,6 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EmptyState } from "./EmptyState"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import { composeEntryPrompt } from "../utils/entryPromptComposer"; + +const { mockGetConfig } = vi.hoisted(() => ({ + mockGetConfig: vi.fn(async () => ({})), +})); const mockCharacterMention = vi.fn< (props: { @@ -17,7 +22,7 @@ const mockCharacterMention = vi.fn< >(); vi.mock("@/hooks/useTauri", () => ({ - getConfig: vi.fn(async () => ({})), + getConfig: mockGetConfig, })); vi.mock("./ChatModelSelector", () => ({ @@ -139,6 +144,7 @@ beforeEach(() => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + mockGetConfig.mockImplementation(async () => ({})); }); afterEach(() => { @@ -301,6 +307,125 @@ describe("EmptyState", () => { expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true); }); + it("社媒主题发送时应默认走 social_post_with_cover skill", async () => { + const onSend = vi.fn< + ( + value: string, + executionStrategy?: "react" | "code_orchestrated" | "auto", + images?: unknown[], + ) => void + >(); + vi.mocked(composeEntryPrompt).mockReturnValue("请输出一篇新品社媒文案"); + + const container = renderEmptyState({ + activeTheme: "social-media", + onSend, + }); + await act(async () => { + await Promise.resolve(); + }); + + const sendButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("开始生成"), + ); + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + expect(onSend).toHaveBeenCalledWith( + "/social_post_with_cover 请输出一篇新品社媒文案", + "react", + undefined, + ); + }); + + it("即使存在历史配置字段,社媒主题仍应自动注入默认 skill", async () => { + mockGetConfig.mockImplementation(async () => ({ + chat_appearance: {}, + })); + vi.mocked(composeEntryPrompt).mockReturnValue("请输出一篇用户访谈纪要"); + + const onSend = vi.fn< + ( + value: string, + executionStrategy?: "react" | "code_orchestrated" | "auto", + images?: unknown[], + ) => void + >(); + const container = renderEmptyState({ + activeTheme: "social-media", + onSend, + }); + await act(async () => { + await Promise.resolve(); + }); + + const sendButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("开始生成"), + ); + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + expect(onSend).toHaveBeenCalledWith( + "/social_post_with_cover 请输出一篇用户访谈纪要", + "react", + undefined, + ); + }); + + it("社媒主题手动选择 skill 时应优先使用手动 skill", async () => { + const onSend = vi.fn< + ( + value: string, + executionStrategy?: "react" | "code_orchestrated" | "auto", + images?: unknown[], + ) => void + >(); + vi.mocked(composeEntryPrompt).mockReturnValue("请输出一篇品牌故事"); + const skill: Skill = { + key: "custom-social-skill", + name: "custom-social-skill", + description: "desc", + directory: "custom-social-skill", + installed: true, + }; + + const container = renderEmptyState({ + activeTheme: "social-media", + onSend, + skills: [skill], + }); + await act(async () => { + await Promise.resolve(); + }); + + const latestCall = + mockCharacterMention.mock.calls[mockCharacterMention.mock.calls.length - 1][0]; + act(() => { + latestCall.onSelectSkill?.(skill); + }); + + const sendButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("开始生成"), + ); + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + expect(onSend).toHaveBeenCalledWith( + "/custom-social-skill 请输出一篇品牌故事", + "react", + undefined, + ); + }); + it("通用主题工具栏应包含附件和深度思考开关", async () => { const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>(); const container = renderEmptyState({ diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index d03fe7548..8b76c3879 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -64,6 +64,8 @@ import iconToutiao from "@/assets/platforms/toutiao.png"; import iconJuejin from "@/assets/platforms/juejin.png"; import iconCsdn from "@/assets/platforms/csdn.png"; +const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; + // --- Animations --- const fadeIn = keyframes` from { opacity: 0; transform: translateY(10px); } @@ -548,12 +550,13 @@ export const EmptyState: React.FC = ({ useEffect(() => { const loadConfigPreferences = async () => { try { - const config = await getConfig(); - if (config.content_creator?.enabled_themes) { - setEnabledThemes(config.content_creator.enabled_themes); + const loadedConfig = await getConfig(); + if (loadedConfig.content_creator?.enabled_themes) { + setEnabledThemes(loadedConfig.content_creator.enabled_themes); } setAppendSelectedTextToRecommendation( - config.chat_appearance?.append_selected_text_to_recommendation ?? true, + loadedConfig.chat_appearance?.append_selected_text_to_recommendation ?? + true, ); } catch (e) { console.error("加载主题配置失败:", e); @@ -610,6 +613,20 @@ export const EmptyState: React.FC = ({ const [ratioPopoverOpen, setRatioPopoverOpen] = useState(false); const [stylePopoverOpen, setStylePopoverOpen] = useState(false); + const wrapTextWithDefaultSkill = (text: string) => { + const wrappedByActiveSkill = wrapTextWithSkill(text); + if (wrappedByActiveSkill !== text) { + return wrappedByActiveSkill; + } + if ( + activeTheme === "social-media" && + !text.trimStart().startsWith("/") + ) { + return `/${SOCIAL_ARTICLE_SKILL_KEY} ${text}`.trim(); + } + return text; + }; + const isEntryTheme = activeTheme === ENTRY_THEME_ID; useEffect(() => { @@ -738,7 +755,11 @@ export const EmptyState: React.FC = ({ }, }); - onSend(wrapTextWithSkill(composedPrompt), executionStrategy, imagesToSend); + onSend( + wrapTextWithDefaultSkill(composedPrompt), + executionStrategy, + imagesToSend, + ); setPendingImages([]); clearActiveSkill(); return; @@ -755,7 +776,11 @@ export const EmptyState: React.FC = ({ prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}] `; if (activeTheme === "planning") prefix = `[计划规划] `; - onSend(wrapTextWithSkill(prefix + input), executionStrategy, imagesToSend); + onSend( + wrapTextWithDefaultSkill(prefix + input), + executionStrategy, + imagesToSend, + ); setPendingImages([]); clearActiveSkill(); }; diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx index c2c0930ce..596bc3a91 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx @@ -227,6 +227,14 @@ export function CharacterMention({ if (!textarea || !showMentions) return; const handleKeyDown = (e: KeyboardEvent) => { + const composing = + (e as KeyboardEvent & { isComposing?: boolean }).isComposing || + e.key === "Process" || + e.keyCode === 229; + if (composing) { + return; + } + if (e.key === "Escape") { setShowMentions(false); e.preventDefault(); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx new file mode 100644 index 000000000..277685a8d --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InputbarCore } from "./InputbarCore"; + +vi.mock("./InputbarTools", () => ({ + InputbarTools: () =>
tools
, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +const renderInputbarCore = () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + return container; +}; + +describe("InputbarCore", () => { + it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", () => { + const container = renderInputbarCore(); + const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; + const inputBar = container.querySelector('[data-testid="inputbar-core-container"]') as HTMLDivElement | null; + expect(textarea).toBeTruthy(); + expect(inputBar).toBeTruthy(); + expect(textarea?.className).toContain("floating-collapsed"); + expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeNull(); + + act(() => { + inputBar?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + textarea?.focus(); + }); + + expect(textarea?.className).not.toContain("floating-collapsed"); + expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeTruthy(); + + act(() => { + inputBar?.dispatchEvent( + new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }), + ); + }); + + expect(textarea?.className).not.toContain("floating-collapsed"); + expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeTruthy(); + + act(() => { + textarea?.blur(); + inputBar?.dispatchEvent( + new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }), + ); + }); + + expect(textarea?.className).toContain("floating-collapsed"); + expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx index 6daed5c01..93ff54ad6 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback, useRef, useState } from "react"; import { Container, InputBarContainer, @@ -25,6 +25,16 @@ import { } from "@/components/ui/tooltip"; import type { MessageImage } from "../../../types"; +const INTERACTIVE_TARGET_SELECTOR = + "button, a, input, textarea, select, option, [role='button'], [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']"; + +function shouldFocusComposerTextarea(target: EventTarget | null): boolean { + if (!(target instanceof Element)) { + return true; + } + return !target.closest(INTERACTIVE_TARGET_SELECTOR); +} + interface InputbarCoreProps { text: string; setText: (text: string) => void; @@ -51,6 +61,16 @@ interface InputbarCoreProps { rightExtra?: React.ReactNode; /** 输入框内部顶部扩展区域(textarea 上方) */ topExtra?: React.ReactNode; + /** 输入框提示文案 */ + placeholder?: string; + /** 工具栏模式 */ + toolMode?: "default" | "attach-only"; + /** 是否显示翻译按钮 */ + showTranslate?: boolean; + /** 是否显示顶部拖拽条 */ + showDragHandle?: boolean; + /** 视觉风格 */ + visualVariant?: "default" | "floating"; } export const InputbarCore: React.FC = ({ @@ -73,7 +93,84 @@ export const InputbarCore: React.FC = ({ leftExtra, rightExtra, topExtra, + placeholder, + toolMode = "default", + showTranslate = true, + showDragHandle = true, + visualVariant = "default", }) => { + const [isComposerExpanded, setIsComposerExpanded] = useState(false); + const inputBarContainerRef = useRef(null); + const isFloatingVariant = visualVariant === "floating"; + const shouldCollapseFloatingTools = + isFloatingVariant && + toolMode === "attach-only" && + !isComposerExpanded && + pendingImages.length === 0; + const shouldUseCompactFloatingComposer = + shouldCollapseFloatingTools && !topExtra; + const containerClassName = [ + isFullscreen ? "flex-1 flex flex-col" : "", + isFloatingVariant ? "floating-composer" : "", + ] + .filter(Boolean) + .join(" "); + const inputBarClassName = [ + isFullscreen ? "flex-1 flex flex-col" : "", + isFloatingVariant ? "floating-composer" : "", + shouldUseCompactFloatingComposer ? "floating-collapsed" : "", + ] + .filter(Boolean) + .join(" "); + const textareaClassName = [ + isFullscreen ? "flex-1 resize-none" : "", + isFloatingVariant ? "floating-composer" : "", + shouldUseCompactFloatingComposer ? "floating-collapsed" : "", + ] + .filter(Boolean) + .join(" "); + const bottomBarClassName = [ + isFloatingVariant ? "floating-composer" : "", + shouldUseCompactFloatingComposer ? "floating-collapsed" : "", + ] + .filter(Boolean) + .join(" "); + const leftSectionClassName = shouldCollapseFloatingTools ? "floating-collapsed" : ""; + const rightSectionClassName = shouldUseCompactFloatingComposer + ? "floating-collapsed" + : ""; + + const handleExpandComposer = useCallback(() => { + if (!isFloatingVariant || toolMode !== "attach-only") { + return; + } + setIsComposerExpanded(true); + }, [isFloatingVariant, toolMode]); + + const handleCollapseComposer = useCallback(() => { + if (!isFloatingVariant || toolMode !== "attach-only" || pendingImages.length > 0) { + return; + } + const activeElement = document.activeElement; + if (activeElement && inputBarContainerRef.current?.contains(activeElement)) { + return; + } + setIsComposerExpanded(false); + }, [isFloatingVariant, pendingImages.length, toolMode]); + + const handleBlurCapture = useCallback(() => { + if (!isFloatingVariant || toolMode !== "attach-only") { + return; + } + window.requestAnimationFrame(() => { + const nextActiveElement = document.activeElement; + if (inputBarContainerRef.current?.contains(nextActiveElement)) { + return; + } + setIsComposerExpanded(false); + }); + }, [isFloatingVariant, toolMode]); + return ( = ({ isFullscreen={isFullscreen} fillHeightWhenFullscreen hasAdditionalContent={pendingImages.length > 0} - maxAutoHeight={300} + maxAutoHeight={isFloatingVariant ? 160 : 300} textareaRef={externalTextareaRef} onEscape={() => onToolClick("fullscreen")} placeholder={ - isFullscreen + placeholder || + (isFullscreen ? "全屏编辑模式,按 ESC 退出,Enter 发送" - : "在这里输入消息, 按 Enter 发送" + : "在这里输入消息, 按 Enter 发送") } > - {({ textareaProps, textareaRef, isPrimaryDisabled, onPrimaryAction }) => ( - - - {!isFullscreen && } + {({ textareaProps, textareaRef, isPrimaryDisabled, onPrimaryAction }) => { + const handleContainerMouseDownCapture = ( + event: React.MouseEvent, + ) => { + handleExpandComposer(); + if (!isFloatingVariant || toolMode !== "attach-only") { + return; + } + if (!shouldFocusComposerTextarea(event.target)) { + return; + } + window.requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }; - {pendingImages.length > 0 && ( - - {pendingImages.map((img, index) => ( - - - onRemoveImage?.(index)}> - - - - ))} - - )} + return ( + + + {!isFullscreen && showDragHandle && } - {topExtra} + {pendingImages.length > 0 && ( + + {pendingImages.map((img, index) => ( + + + onRemoveImage?.(index)}> + + + + ))} + + )} - + {topExtra} - - - {leftExtra && ( -
{leftExtra}
- )} - -
+ - - {rightExtra} - - - - onToolClick("translate")}> - - - - 翻译 - - - - {isLoading ? ( - - ) : ( - + + + {leftExtra && ( +
{leftExtra}
)} -
-
-
-
-
- )} + {!shouldCollapseFloatingTools ? ( + + ) : null} + + + + {rightExtra} + {showTranslate ? ( + + + + onToolClick("translate")}> + + + + 翻译 + + + ) : null} + + {isLoading ? ( + + ) : ( + + )} + + + +
+
+ ); + }}
); }; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx index b81a50698..5ddffa9f2 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx @@ -18,6 +18,7 @@ interface InputbarToolsProps { activeTools?: Record; executionStrategy?: "react" | "code_orchestrated" | "auto"; showExecutionStrategy?: boolean; + toolMode?: "default" | "attach-only"; /** 画布是否打开(兼容保留,不再展示画布图标) */ isCanvasOpen?: boolean; } @@ -27,6 +28,7 @@ export const InputbarTools: React.FC = ({ activeTools = {}, executionStrategy = "react", showExecutionStrategy = false, + toolMode = "default", }) => { const modeLabel = executionStrategy === "auto" @@ -49,53 +51,57 @@ export const InputbarTools: React.FC = ({ 上传文件 - - - onToolClick?.("thinking")} - className={activeTools["thinking"] ? "active" : ""} - > - - - - - 深度思考 {activeTools["thinking"] ? "(已开启)" : ""} - - + {toolMode === "default" ? ( + <> + + + onToolClick?.("thinking")} + className={activeTools["thinking"] ? "active" : ""} + > + + + + + 深度思考 {activeTools["thinking"] ? "(已开启)" : ""} + + - - - onToolClick?.("web_search")} - className={activeTools["web_search"] ? "active" : ""} - > - - - - - 联网搜索 {activeTools["web_search"] ? "(已开启)" : ""} - - + + + onToolClick?.("web_search")} + className={activeTools["web_search"] ? "active" : ""} + > + + + + + 联网搜索 {activeTools["web_search"] ? "(已开启)" : ""} + + - {showExecutionStrategy && ( - - - onToolClick?.("execution_strategy")} - className={strategyEnabled ? "active" : ""} - > - - - - 执行模式: {modeLabel} - - )} + {showExecutionStrategy && ( + + + onToolClick?.("execution_strategy")} + className={strategyEnabled ? "active" : ""} + > + + + + 执行模式: {modeLabel} + + )} + + ) : null} ); diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index 2b140ece2..d5b98669d 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -16,6 +16,12 @@ const mockInputbarCore = vi.fn( (props: { onToolClick?: (tool: string) => void; activeTools?: Record; + onSend?: () => void; + rightExtra?: React.ReactNode; + topExtra?: React.ReactNode; + placeholder?: string; + toolMode?: "default" | "attach-only"; + showTranslate?: boolean; }) => (
+
{props.rightExtra}
+
{props.topExtra}
), ); @@ -36,6 +47,12 @@ vi.mock("./components/InputbarCore", () => ({ InputbarCore: (props: { onToolClick?: (tool: string) => void; activeTools?: Record; + onSend?: () => void; + rightExtra?: React.ReactNode; + topExtra?: React.ReactNode; + placeholder?: string; + toolMode?: "default" | "attach-only"; + showTranslate?: boolean; }) => mockInputbarCore(props), })); @@ -152,22 +169,22 @@ afterEach(() => { vi.clearAllMocks(); }); -function renderInputbar() { +function renderInputbar(props?: Partial>) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + const defaultProps: React.ComponentProps = { + input: "", + setInput: vi.fn(), + onSend: vi.fn(), + isLoading: false, + characters: [], + skills: [], + }; + act(() => { - root.render( - , - ); + root.render(); }); mountedRoots.push({ root, container }); @@ -175,17 +192,22 @@ function renderInputbar() { } describe("Inputbar", () => { - it("即使角色和技能为空,也应挂载 CharacterMention", () => { + it("即使角色和技能为空,也应挂载 CharacterMention", async () => { const container = renderInputbar(); + await act(async () => { + await Promise.resolve(); + }); const mention = container.querySelector('[data-testid="character-mention-stub"]'); expect(mention).toBeTruthy(); - expect(mockCharacterMention).toHaveBeenCalledTimes(1); - expect(mockCharacterMention.mock.calls[0][0].characters).toEqual([]); - expect(mockCharacterMention.mock.calls[0][0].skills).toEqual([]); + expect(mockCharacterMention.mock.calls.length).toBeGreaterThan(0); + const latestCall = + mockCharacterMention.mock.calls[mockCharacterMention.mock.calls.length - 1][0]; + expect(latestCall.characters).toEqual([]); + expect(latestCall.skills).toEqual([]); }); - it("受控模式下点击联网搜索应透传状态变更", () => { + it("受控模式下点击联网搜索应透传状态变更", async () => { const onToolStatesChange = vi.fn(); const container = document.createElement("div"); document.body.appendChild(container); @@ -207,6 +229,9 @@ describe("Inputbar", () => { }); mountedRoots.push({ root, container }); + await act(async () => { + await Promise.resolve(); + }); const toggleButton = container.querySelector( '[data-testid="toggle-web-search"]', @@ -222,4 +247,258 @@ describe("Inputbar", () => { thinking: false, }); }); + + it("社媒主题默认应自动注入 social_post_with_cover skill", async () => { + const onSend = vi.fn(); + const container = renderInputbar({ + activeTheme: "social-media", + input: "写一篇春季上新种草文案", + onSend, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const sendButton = container.querySelector( + '[data-testid="send-btn"]', + ) as HTMLButtonElement | null; + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + expect(onSend).toHaveBeenCalledWith( + undefined, + false, + false, + "/social_post_with_cover 写一篇春季上新种草文案", + "react", + ); + }); + + it("社媒主题输入 slash 命令时不应重复注入默认 skill", async () => { + const onSend = vi.fn(); + const container = renderInputbar({ + activeTheme: "social-media", + input: "/custom_skill 写一篇品牌故事", + onSend, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const sendButton = container.querySelector( + '[data-testid="send-btn"]', + ) as HTMLButtonElement | null; + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + expect(onSend).toHaveBeenCalledWith( + undefined, + false, + false, + undefined, + "react", + ); + }); + + it("主题工作台模式应启用 PRD 浮层输入配置", async () => { + renderInputbar({ + variant: "theme_workbench", + providerType: "openai", + setProviderType: vi.fn(), + model: "gpt-4.1", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const latestCall = + mockInputbarCore.mock.calls[mockInputbarCore.mock.calls.length - 1]?.[0]; + expect(latestCall).toBeTruthy(); + expect(latestCall.toolMode).toBe("attach-only"); + expect(latestCall.showTranslate).toBe(false); + expect(latestCall.placeholder).toContain("试着输入任何指令"); + expect(latestCall.rightExtra).toBeUndefined(); + }); + + it("主题工作台在待启动状态下不应显示闸门条", async () => { + const container = renderInputbar({ + variant: "theme_workbench", + themeWorkbenchGate: { + key: "draft_start", + title: "编排待启动", + status: "idle", + description: "输入目标后将自动进入编排执行。", + }, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).not.toContain("编排待启动"); + expect(container.textContent).not.toContain("待启动"); + }); + + it("主题工作台闸门快捷操作应能快速填充输入", async () => { + const setInput = vi.fn(); + const container = renderInputbar({ + variant: "theme_workbench", + setInput, + themeWorkbenchGate: { + key: "topic_select", + title: "选题闸门", + status: "waiting", + description: "请选择优先推进的选题方向。", + }, + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("选题闸门"); + expect(container.textContent).not.toContain("当前闸门"); + expect(container.textContent).not.toContain("请选择优先推进的选题方向。"); + + const quickActionButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("生成 3 个选题"), + ); + expect(quickActionButton).toBeTruthy(); + + act(() => { + quickActionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(setInput).toHaveBeenCalledWith( + "请给我 3 个可执行选题方向,并说明目标读者与传播价值。", + ); + }); + + it("主题工作台生成中应展示任务面板并支持停止", async () => { + const onStop = vi.fn(); + const container = renderInputbar({ + variant: "theme_workbench", + isLoading: true, + onStop, + workflowSteps: [ + { id: "research", title: "检索项目素材", status: "active" }, + { id: "write", title: "编写正文草稿", status: "pending" }, + ], + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("当前待办"); + expect(container.textContent).toContain("正在生成中"); + expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull(); + + const stopButton = container.querySelector( + '[data-testid="theme-workbench-stop"]', + ) as HTMLButtonElement | null; + expect(stopButton).toBeTruthy(); + act(() => { + stopButton?.click(); + }); + expect(onStop).toHaveBeenCalledTimes(1); + }); + + it("主题工作台生成中应支持折叠与展开待办列表", async () => { + const container = renderInputbar({ + variant: "theme_workbench", + isLoading: true, + workflowSteps: [ + { id: "research", title: "检索项目素材", status: "active" }, + { id: "write", title: "编写正文草稿", status: "pending" }, + ], + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("检索项目素材"); + + const collapseButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.getAttribute("aria-label") === "折叠待办列表", + ); + expect(collapseButton).toBeTruthy(); + + act(() => { + collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("检索项目素材"); + + const expandButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.getAttribute("aria-label") === "展开待办列表", + ); + expect(expandButton).toBeTruthy(); + + act(() => { + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("检索项目素材"); + }); + + it("主题工作台在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => { + const container = renderInputbar({ + variant: "theme_workbench", + isLoading: false, + themeWorkbenchRunState: "auto_running", + workflowSteps: [ + { id: "research", title: "检索项目素材", status: "active" }, + { id: "write", title: "编写正文草稿", status: "pending" }, + ], + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("当前待办"); + expect(container.textContent).toContain("正在生成中"); + expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull(); + }); + + it("主题工作台在 await_user_decision 状态下应显示输入框", async () => { + const container = renderInputbar({ + variant: "theme_workbench", + isLoading: true, + themeWorkbenchRunState: "await_user_decision", + workflowSteps: [ + { id: "topic", title: "等待用户确认选题", status: "pending" }, + ], + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="inputbar-core"]')).toBeTruthy(); + expect(container.textContent).not.toContain("正在生成中"); + }); + }); diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index d6d17b2e0..bf12dadb1 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -7,7 +7,16 @@ import type { MessageImage } from "../../types"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import { TaskFileList, type TaskFile } from "../TaskFiles"; -import { FolderOpen, ChevronUp, Code2 } from "lucide-react"; +import { + FolderOpen, + ChevronUp, + ChevronDown, + Code2, + Loader2, + Clock3, + AlertCircle, + Sparkles, +} from "lucide-react"; import { useActiveSkill } from "./hooks/useActiveSkill"; import { SkillBadge } from "./components/SkillBadge"; import { ChatModelSelector } from "../ChatModelSelector"; @@ -19,6 +28,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { createAgentInputAdapter } from "@/components/input-kit"; +import type { StepStatus } from "@/components/content-creator/types"; // 任务文件触发器区域(在输入框上方,与输入框对齐) const TaskFilesArea = styled.div` @@ -124,6 +134,281 @@ const HintModel = styled.span` const NOOP_SET_PROVIDER_TYPE = (_type: string) => {}; const NOOP_SET_MODEL = (_model: string) => {}; +const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; + +const ThemeWorkbenchGateStrip = styled.div` + margin: 0 12px 8px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 10px; + padding: 8px 10px; + border-radius: 14px; + border: 1px solid hsl(var(--border) / 0.92); + background: hsl(var(--muted) / 0.78); + box-shadow: none; + opacity: 1; + + @media (prefers-color-scheme: dark) { + background: hsl(222 18% 14% / 0.96); + border-color: hsl(217 18% 24% / 0.95); + } +`; + +const ThemeWorkbenchGateMeta = styled.div` + min-width: 0; + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +`; + +const ThemeWorkbenchGateIcon = styled.span` + width: 22px; + height: 22px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + border: 1px solid hsl(var(--border) / 0.9); + flex-shrink: 0; +`; + +const ThemeWorkbenchGateTitle = styled.span` + font-size: 12px; + color: hsl(var(--foreground) / 0.86); + font-weight: 600; + line-height: 1.4; +`; + +const ThemeWorkbenchGateStatus = styled.span<{ + $status: "running" | "waiting" | "idle"; +}>` + font-size: 11px; + line-height: 1; + border-radius: 999px; + padding: 4px 8px; + color: ${({ $status }) => + $status === "waiting" + ? "hsl(var(--destructive))" + : $status === "running" + ? "hsl(var(--primary))" + : "hsl(var(--muted-foreground))"}; + background: ${({ $status }) => + $status === "waiting" + ? "hsl(var(--destructive) / 0.08)" + : $status === "running" + ? "hsl(var(--primary) / 0.1)" + : "hsl(var(--muted) / 0.7)"}; +`; + +const ThemeWorkbenchQuickActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-left: auto; +`; + +const ThemeWorkbenchQuickButton = styled.button` + border: 1px solid hsl(var(--border) / 0.88); + border-radius: 999px; + background: hsl(var(--background)); + color: hsl(var(--foreground) / 0.82); + font-size: 11px; + line-height: 1.2; + padding: 5px 10px; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary) / 0.22); + color: hsl(var(--foreground)); + background: hsl(var(--background)); + } +`; + +const ThemeWorkbenchGeneratingWrap = styled.div` + margin: 0 10px 10px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ThemeWorkbenchTaskCard = styled.div` + border: 1px solid hsl(var(--border) / 0.78); + border-radius: 15px; + background: hsl(var(--background)); + box-shadow: 0 8px 20px hsl(var(--foreground) / 0.05); + padding: 11px 12px 10px; +`; + +const ThemeWorkbenchTaskHead = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + margin-bottom: 8px; +`; + +const ThemeWorkbenchTaskHeadButton = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: transparent; + color: inherit; + padding: 0; + cursor: pointer; +`; + +const ThemeWorkbenchTaskHeadChevron = styled.span<{ $collapsed: boolean }>` + display: inline-flex; + transition: transform 0.2s ease; + transform: ${({ $collapsed }) => + $collapsed ? "rotate(-90deg)" : "rotate(0deg)"}; +`; + +const ThemeWorkbenchTaskList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ThemeWorkbenchTaskRow = styled.div` + display: flex; + align-items: center; + gap: 10px; + min-height: 34px; + min-width: 0; +`; + +const ThemeWorkbenchTaskIcon = styled.span<{ $kind: "active" | "pending" | "error" }>` + width: 30px; + height: 30px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${({ $kind }) => + $kind === "active" + ? "hsl(var(--primary) / 0.12)" + : $kind === "error" + ? "hsl(var(--destructive) / 0.1)" + : "hsl(38 100% 92%)"}; + color: ${({ $kind }) => + $kind === "active" + ? "hsl(var(--primary))" + : $kind === "error" + ? "hsl(var(--destructive))" + : "hsl(30 90% 42%)"}; + flex-shrink: 0; +`; + +const ThemeWorkbenchTaskText = styled.span` + flex: 1; + font-size: 14px; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const ThemeWorkbenchTaskStatus = styled.span<{ $kind: "active" | "pending" | "error" }>` + font-size: 11px; + border-radius: 999px; + padding: 4px 10px; + line-height: 1; + font-weight: 600; + color: ${(props) => + props.$kind === "active" + ? "hsl(var(--primary))" + : props.$kind === "error" + ? "hsl(var(--destructive))" + : "hsl(35 95% 35%)"}; + background: ${(props) => + props.$kind === "active" + ? "hsl(var(--primary) / 0.14)" + : props.$kind === "error" + ? "hsl(var(--destructive) / 0.12)" + : "hsl(36 100% 90%)"}; +`; + +const ThemeWorkbenchRunningBar = styled.div` + min-height: 44px; + border: 1px solid hsl(var(--border)); + border-radius: 11px; + background: hsl(var(--background)); + box-shadow: 0 4px 14px hsl(var(--foreground) / 0.04); + display: flex; + align-items: center; + gap: 7px; + padding: 7px 10px; +`; + +const ThemeWorkbenchRunningIcon = styled.span` + color: hsl(var(--primary)); + display: inline-flex; + flex-shrink: 0; +`; + +const ThemeWorkbenchRunningSub = styled.span` + flex: 1; + min-width: 0; + font-size: 12px; + color: hsl(var(--muted-foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const ThemeWorkbenchRunningMain = styled.span` + color: hsl(var(--primary)); + font-weight: 600; + margin-right: 2px; + font-size: 14px; +`; + +const ThemeWorkbenchStopButton = styled.button` + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.28); + display: inline-flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); + flex-shrink: 0; + position: relative; + + &:hover { + color: hsl(var(--destructive)); + border-color: hsl(var(--destructive) / 0.5); + background: hsl(var(--destructive) / 0.06); + } +`; + +const ThemeWorkbenchStopGlyph = styled.span` + width: 12px; + height: 12px; + border: 1.5px solid currentColor; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + + &::after { + content: ""; + width: 3px; + height: 3px; + border-radius: 999px; + background: currentColor; + } +`; interface HintRouteItem { hint: string; @@ -131,16 +416,89 @@ interface HintRouteItem { model: string; } +interface ThemeWorkbenchQuickAction { + id: string; + label: string; + prompt: string; +} + export interface InputbarToolStates { webSearch: boolean; thinking: boolean; } +export interface ThemeWorkbenchGateState { + key: string; + title: string; + status: "running" | "waiting" | "idle"; + description: string; +} + +interface ThemeWorkbenchWorkflowStep { + id: string; + title: string; + status: StepStatus; +} + const DEFAULT_INPUTBAR_TOOL_STATES: InputbarToolStates = { webSearch: false, thinking: false, }; +function resolveThemeWorkbenchQuickActions( + gateKey?: string, +): ThemeWorkbenchQuickAction[] { + switch (gateKey) { + case "topic_select": + return [ + { + id: "topic-options", + label: "生成 3 个选题", + prompt: "请给我 3 个可执行选题方向,并说明目标读者与传播价值。", + }, + { + id: "topic-choose-b", + label: "采纳 B 方向", + prompt: "我采纳 B 方向,请继续推进主稿与配图编排。", + }, + ]; + case "write_mode": + return [ + { + id: "write-fast", + label: "快速模式出稿", + prompt: "请按快速模式生成可发布主稿,并标注可优化段落。", + }, + { + id: "write-coach", + label: "教练模式引导", + prompt: "请按教练模式逐步提问我,帮助补充真实案例后再成稿。", + }, + ]; + case "publish_confirm": + return [ + { + id: "publish-checklist", + label: "发布前检查", + prompt: "请给我发布前检查清单,包含标题、封面、平台合规与风险项。", + }, + { + id: "publish-adapt", + label: "双平台适配", + prompt: "请将主稿适配为公众号和小红书两个版本,并输出差异点。", + }, + ]; + default: + return [ + { + id: "next-step", + label: "继续编排", + prompt: "请继续按照当前编排推进,并在关键闸门前向我确认。", + }, + ]; + } +} + interface InputbarProps { input: string; setInput: (value: string) => void; @@ -190,6 +548,10 @@ interface InputbarProps { onToolStatesChange?: (states: InputbarToolStates) => void; activeTheme?: string; onManageProviders?: () => void; + variant?: "default" | "theme_workbench"; + themeWorkbenchGate?: ThemeWorkbenchGateState | null; + workflowSteps?: ThemeWorkbenchWorkflowStep[]; + themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision"; } export const Inputbar: React.FC = ({ @@ -221,6 +583,10 @@ export const Inputbar: React.FC = ({ onToolStatesChange, activeTheme, onManageProviders, + variant = "default", + themeWorkbenchGate, + workflowSteps = [], + themeWorkbenchRunState, }) => { const [localActiveTools, setLocalActiveTools] = useState< Record @@ -230,6 +596,8 @@ export const Inputbar: React.FC = ({ ); const [pendingImages, setPendingImages] = useState([]); const [isFullscreen, setIsFullscreen] = useState(false); + const [themeWorkbenchQueueCollapsed, setThemeWorkbenchQueueCollapsed] = + useState(false); const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill(); const fileInputRef = useRef(null); const textareaRef = useRef(null); @@ -242,6 +610,38 @@ export const Inputbar: React.FC = ({ const webSearchEnabled = toolStates?.webSearch ?? localToolStates.webSearch; const thinkingEnabled = toolStates?.thinking ?? localToolStates.thinking; + const isThemeWorkbenchVariant = variant === "theme_workbench"; + const themeWorkbenchQuickActions = useMemo( + () => + isThemeWorkbenchVariant + ? resolveThemeWorkbenchQuickActions(themeWorkbenchGate?.key) + : [], + [isThemeWorkbenchVariant, themeWorkbenchGate?.key], + ); + const themeWorkbenchQueueItems = useMemo(() => { + if (!isThemeWorkbenchVariant) { + return []; + } + const visibleSteps = workflowSteps + .filter((step) => step.status !== "completed" && step.status !== "skipped") + .slice(0, 3); + if (visibleSteps.length > 0) { + return visibleSteps; + } + if (themeWorkbenchGate) { + return [ + { + id: `gate-${themeWorkbenchGate.key}`, + title: themeWorkbenchGate.title, + status: + themeWorkbenchGate.status === "waiting" + ? ("pending" as StepStatus) + : ("active" as StepStatus), + }, + ]; + } + return []; + }, [isThemeWorkbenchVariant, themeWorkbenchGate, workflowSteps]); const activeTools = useMemo>( () => ({ @@ -299,6 +699,17 @@ export const Inputbar: React.FC = ({ const handleHintKeyDown = useCallback( (e: React.KeyboardEvent) => { + const nativeEvent = e.nativeEvent as KeyboardEvent & { + isComposing?: boolean; + }; + if ( + e.isComposing || + nativeEvent.isComposing || + nativeEvent.key === "Process" || + nativeEvent.keyCode === 229 + ) { + return; + } if (!showHintPopup || hintRoutes.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); @@ -516,9 +927,16 @@ export const Inputbar: React.FC = ({ } // 如果有 activeSkill,拼接 /skill.key 前缀 - const textOverride = activeSkill - ? `/${activeSkill.key} ${input}`.trim() - : undefined; + let textOverride: string | undefined; + if (activeSkill) { + textOverride = `/${activeSkill.key} ${input}`.trim(); + } else if ( + activeTheme === "social-media" && + input.trim() && + !input.trimStart().startsWith("/") + ) { + textOverride = `/${SOCIAL_ARTICLE_SKILL_KEY} ${input}`.trim(); + } onSend( pendingImages.length > 0 ? pendingImages : undefined, @@ -535,6 +953,7 @@ export const Inputbar: React.FC = ({ clearActiveSkill, executionStrategy, input, + activeTheme, onSend, pendingImages, thinkingEnabled, @@ -586,8 +1005,57 @@ export const Inputbar: React.FC = ({ ); const shouldRenderModelSelector = Boolean( - providerType && setProviderType && model && setModel, + !isThemeWorkbenchVariant && + providerType && + setProviderType && + model && + setModel, ); + const topExtra = activeSkill ? ( + + ) : undefined; + + const themeWorkbenchGateStrip = + isThemeWorkbenchVariant && + themeWorkbenchGate && + themeWorkbenchGate.status !== "idle" ? ( + + + + + + {themeWorkbenchGate.title} + + {themeWorkbenchGate.status === "waiting" + ? "等待决策" + : themeWorkbenchGate.status === "running" + ? "自动执行中" + : "待启动"} + + + {themeWorkbenchQuickActions.length > 0 ? ( + + {themeWorkbenchQuickActions.map((action) => ( + { + inputAdapter.actions.setText(action.prompt); + }} + > + {action.label} + + ))} + + ) : null} + + ) : null; + + const renderThemeWorkbenchGeneratingPanel = isThemeWorkbenchVariant + ? themeWorkbenchRunState + ? themeWorkbenchRunState === "auto_running" + : inputAdapter.state.isSending + : false; return (
= ({ style={{ display: "none" }} onChange={handleFileSelect} /> - {/* 角色与技能引用组件 */} - - - ) : undefined - } - leftExtra={ - !isFullscreen ? ( -
- {shouldRenderModelSelector && inputAdapter.model ? ( - - ) : null} -
- ) : undefined - } - rightExtra={ - !isFullscreen && setExecutionStrategy ? ( - - ) : undefined - } - /> + ) : undefined + } + rightExtra={ + !isFullscreen && !isThemeWorkbenchVariant && setExecutionStrategy ? ( + + ) : undefined + } + /> + + )}
); }; diff --git a/src/components/agent/chat/components/Inputbar/styles.ts b/src/components/agent/chat/components/Inputbar/styles.ts index a7ea52636..e8d2ab36d 100644 --- a/src/components/agent/chat/components/Inputbar/styles.ts +++ b/src/components/agent/chat/components/Inputbar/styles.ts @@ -31,6 +31,10 @@ export const Container = styled.div` width: 100%; max-width: none; margin: 0; + + &.floating-composer { + padding: 0 0 4px 0; + } `; export const InputBarContainer = styled.div` @@ -58,6 +62,50 @@ export const InputBarContainer = styled.div` border: 2px dashed #2ecc71; background-color: rgba(46, 204, 113, 0.03); } + + &.floating-composer { + border-radius: 14px; + padding-top: 1px; + background: linear-gradient(180deg, #fcfdff 0%, #f7f9fc 100%); + border-color: #d7e0ea; + box-shadow: + 0 10px 26px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.78); + backdrop-filter: none; + opacity: 1; + + @media (prefers-color-scheme: dark) { + background: linear-gradient(180deg, #2a2f39 0%, #232831 100%); + border-color: #404958; + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.03); + } + } + + &.floating-composer.floating-collapsed { + padding-top: 0; + min-height: 44px; + cursor: text; + } + + &.floating-composer:focus-within { + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + border-color: #c5d3e2; + box-shadow: + 0 0 0 3px rgba(191, 219, 254, 0.38), + 0 12px 28px rgba(15, 23, 42, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.86); + + @media (prefers-color-scheme: dark) { + background: linear-gradient(180deg, #303744 0%, #272d37 100%); + border-color: #556174; + box-shadow: + 0 0 0 3px rgba(96, 165, 250, 0.16), + 0 12px 28px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + } + } `; export const StyledTextarea = styled.textarea` @@ -78,8 +126,22 @@ export const StyledTextarea = styled.textarea` color: hsl(var(--foreground)); min-height: 30px; + &.floating-composer { + padding: 0 12px; + font-size: 13px; + line-height: 1.4; + min-height: 22px; + } + + &.floating-composer.floating-collapsed { + padding: 10px 48px 10px 14px; + min-height: 42px; + line-height: 1.35; + overflow: hidden; + } + &::placeholder { - color: hsl(var(--muted-foreground)); + color: hsl(var(--muted-foreground) / 0.78); } &::-webkit-scrollbar { @@ -103,6 +165,26 @@ export const BottomBar = styled.div` z-index: 2; flex-shrink: 0; min-width: 0; + + &.floating-composer { + padding: 2px 8px 6px; + height: 34px; + gap: 10px; + border-top: 1px solid hsl(var(--border) / 0.75); + } + + &.floating-composer.floating-collapsed { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + width: auto; + min-width: 0; + height: auto; + padding: 0; + gap: 0; + border-top: none; + } `; // ... (LeftSection and RightSection seem fine without vars, skipping for brevity of replace block if possible but might as well include to be safe or target specific chunks) @@ -117,6 +199,11 @@ export const LeftSection = styled.div` overflow-y: hidden; scrollbar-width: none; -ms-overflow-style: none; + transition: + opacity 0.16s ease, + width 0.16s ease, + flex-basis 0.16s ease, + margin 0.16s ease; &::-webkit-scrollbar { display: none; @@ -125,6 +212,13 @@ export const LeftSection = styled.div` > * { flex-shrink: 0; } + + &.floating-collapsed { + flex: 0 0 0; + width: 0; + opacity: 0; + pointer-events: none; + } `; export const RightSection = styled.div` @@ -133,6 +227,10 @@ export const RightSection = styled.div` gap: 6px; /* Cherry Studio Exact: 6px */ flex-shrink: 0; margin-left: 4px; + + &.floating-collapsed { + margin-left: 0; + } `; // --- InputbarTools Styles --- diff --git a/src/components/agent/chat/components/MarkdownRenderer.tsx b/src/components/agent/chat/components/MarkdownRenderer.tsx index 84409b0a6..ffb6adfc8 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.tsx @@ -358,9 +358,6 @@ export const MarkdownRenderer: React.FC = memo( console.error("[MarkdownRenderer] 图片加载失败:", img.alt); (e.target as HTMLImageElement).style.display = "none"; }} - onLoad={() => { - console.log("[MarkdownRenderer] 图片加载成功:", img.alt); - }} /> = memo( : codeChildren || "", ).replace(/\n$/, ""); - // 调试:输出检测到的语言 - if (language) { - console.log( - "[MarkdownRenderer] pre 组件检测到语言:", - language, - ); - } - // 如果是 a2ui 代码块,特殊处理 if (language === "a2ui") { - console.log( - "[MarkdownRenderer] a2ui 代码块内容长度:", - codeContent.length, - ); const parsed = parseA2UIJson(codeContent); if (parsed) { - console.log("[MarkdownRenderer] a2ui 解析成功,渲染表单"); // 解析成功,直接渲染 A2UI 组件(不包裹在 pre 中) return ( = memo( /> ); } else { - console.log( - "[MarkdownRenderer] a2ui 解析失败,显示加载状态", - ); // 解析失败(可能是流式输出中,JSON 还不完整) return ( diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 9330ff166..5c4f1b24f 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -44,7 +44,7 @@ interface MessageListProps { onCodeBlockClick?: (language: string, code: string) => void; } -export const MessageList: React.FC = ({ +const MessageListInner: React.FC = ({ messages, onDeleteMessage, onEditMessage, @@ -338,3 +338,6 @@ export const MessageList: React.FC = ({ ); }; + +export const MessageList = React.memo(MessageListInner); +MessageList.displayName = "MessageList"; diff --git a/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx b/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx new file mode 100644 index 000000000..3ea963f20 --- /dev/null +++ b/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx @@ -0,0 +1,939 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeWorkbenchSidebar } from "./ThemeWorkbenchSidebar"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; +const mockWriteClipboardText = vi.fn(); +const { + mockRevealSessionFileInFinder, + mockOpenSessionFileWithDefaultApp, + mockToastError, + mockToastSuccess, + mockOpenDialog, +} = vi.hoisted(() => ({ + mockRevealSessionFileInFinder: vi.fn(), + mockOpenSessionFileWithDefaultApp: vi.fn(), + mockToastError: vi.fn(), + mockToastSuccess: vi.fn(), + mockOpenDialog: vi.fn(), +})); + +vi.mock("@/lib/api/session-files", () => ({ + revealFileInFinder: mockRevealSessionFileInFinder, + openFileWithDefaultApp: mockOpenSessionFileWithDefaultApp, +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: mockOpenDialog, +})); + +vi.mock("sonner", () => ({ + toast: { + error: mockToastError, + success: mockToastSuccess, + }, +})); + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: mockWriteClipboardText, + }, + }); + mockWriteClipboardText.mockResolvedValue(undefined); + mockRevealSessionFileInFinder.mockResolvedValue(undefined); + mockOpenSessionFileWithDefaultApp.mockResolvedValue(undefined); + mockOpenDialog.mockResolvedValue(null); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderSidebar( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: React.ComponentProps = { + onNewTopic: vi.fn(), + onSwitchTopic: vi.fn(), + onDeleteTopic: vi.fn(), + branchItems: [ + { + id: "topic-a", + title: "话题 A", + status: "in_progress", + isCurrent: true, + }, + ], + onSetBranchStatus: vi.fn(), + workflowSteps: [ + { id: "brief", title: "明确需求", status: "completed" }, + { id: "create", title: "创作内容", status: "active" }, + ], + contextSearchQuery: "品牌", + onContextSearchQueryChange: vi.fn(), + contextSearchMode: "web", + onContextSearchModeChange: vi.fn(), + contextSearchLoading: false, + contextSearchError: null, + onSubmitContextSearch: vi.fn(), + contextItems: [ + { + id: "search:web:brand", + name: "品牌话题观察", + source: "search", + searchMode: "web", + query: "品牌 2026", + previewText: "品牌讨论聚焦产品定位、渠道节奏与转化质量。", + citations: [ + { title: "官方博客", url: "https://example.com/blog" }, + ], + active: true, + }, + ], + onToggleContextActive: vi.fn(), + contextBudget: { + activeCount: 1, + activeCountLimit: 12, + estimatedTokens: 600, + tokenLimit: 32000, + }, + activityLogs: [ + { + id: "log-1", + name: "social_post_with_cover", + status: "completed", + timeLabel: "10:30", + applyTarget: "封面/插图", + contextIds: ["material:1"], + gateKey: "write_mode", + runId: "run-abcdef123456", + source: "skill", + }, + ], + onViewRunDetail: vi.fn(), + activeRunDetail: null, + activeRunDetailLoading: false, + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { container, props: { ...defaultProps, ...props } }; +} + +describe("ThemeWorkbenchSidebar", () => { + it("传入折叠回调时应显示折叠按钮并可触发", () => { + const onRequestCollapse = vi.fn(); + const { container } = renderSidebar({ onRequestCollapse }); + + const collapseButton = container.querySelector( + 'button[aria-label="折叠上下文侧栏"]', + ) as HTMLButtonElement | null; + expect(collapseButton).toBeTruthy(); + if (collapseButton) { + act(() => { + collapseButton.click(); + }); + } + expect(onRequestCollapse).toHaveBeenCalledTimes(1); + }); + + it("点击添加上下文应打开添加弹窗", () => { + const { container } = renderSidebar(); + + const addContextButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("添加上下文"), + ); + expect(addContextButton).toBeTruthy(); + if (addContextButton) { + act(() => { + addContextButton.click(); + }); + } + + expect(container.textContent).toContain("添加新上下文"); + expect(container.textContent).toContain("上传文件"); + expect(container.textContent).toContain("网站链接"); + expect(container.textContent).toContain("输入文本"); + }); + + it("输入文本上下文后确认应触发回调", async () => { + const onAddTextContext = vi.fn().mockResolvedValue(undefined); + const { container } = renderSidebar({ onAddTextContext }); + + const addContextButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("添加上下文"), + ); + expect(addContextButton).toBeTruthy(); + if (addContextButton) { + act(() => { + addContextButton.click(); + }); + } + + const textButton = container.querySelector( + 'button[aria-label="输入文本上下文"]', + ) as HTMLButtonElement | null; + expect(textButton).toBeTruthy(); + if (textButton) { + act(() => { + textButton.click(); + }); + } + + const textarea = container.querySelector( + 'textarea[placeholder="在此粘贴或输入文本..."]', + ) as HTMLTextAreaElement | null; + expect(textarea).toBeTruthy(); + if (textarea) { + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + setter?.call(textarea, "这是一段用于测试的上下文内容"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + const confirmButton = container.querySelector( + 'button[aria-label="确认添加文本上下文"]', + ) as HTMLButtonElement | null; + await act(async () => { + await Promise.resolve(); + }); + if (confirmButton) { + await act(async () => { + confirmButton.click(); + await Promise.resolve(); + }); + } + + expect(onAddTextContext).toHaveBeenCalledTimes(1); + expect(onAddTextContext).toHaveBeenCalledWith({ + content: "这是一段用于测试的上下文内容", + }); + }); + + it("应展示新的双 tab 与紧凑上下文列表结构", () => { + const { container } = renderSidebar(); + 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("项目资料"); + }); + + it("应展示新的搜索上下文输入区", () => { + const { container } = renderSidebar(); + expect(container.textContent).toContain("添加上下文"); + + const searchInput = container.querySelector( + 'input[placeholder="搜索网络添加新上下文"]', + ) as HTMLInputElement | null; + expect(searchInput).toBeTruthy(); + expect(searchInput?.value).toBe("品牌"); + }); + + it("应支持触发上下文搜索与切换来源", () => { + const onSubmitContextSearch = vi.fn(); + const onContextSearchModeChange = vi.fn(); + const { container } = renderSidebar({ + onSubmitContextSearch, + onContextSearchModeChange, + }); + + const submitButton = container.querySelector( + 'button[aria-label="提交上下文搜索"]', + ) as HTMLButtonElement | null; + expect(submitButton).toBeTruthy(); + if (submitButton) { + act(() => { + submitButton.click(); + }); + } + expect(onSubmitContextSearch).toHaveBeenCalledTimes(1); + + const triggerButton = container.querySelector( + 'button[aria-label="选择上下文搜索来源"]', + ) as HTMLButtonElement | null; + expect(triggerButton).toBeTruthy(); + if (triggerButton) { + act(() => { + triggerButton.click(); + }); + } + + const socialMenuText = Array.from(container.querySelectorAll("span")).find( + (node) => node.textContent === "社交媒体", + ); + const socialMenuItem = socialMenuText?.closest("div"); + expect(socialMenuItem).toBeTruthy(); + if (socialMenuItem) { + act(() => { + socialMenuItem.click(); + }); + } + + expect(onContextSearchModeChange).toHaveBeenCalledWith("social"); + }); + + it("应按标题列表展示搜索结果,并支持进入详情查看来源", () => { + const { container } = renderSidebar(); + + expect(container.textContent).toContain("上下文列表"); + expect(container.textContent).toContain("品牌话题观察"); + expect(container.textContent).not.toContain("检索词:品牌 2026"); + expect(container.textContent).not.toContain("品牌讨论聚焦产品定位"); + expect((container.textContent?.match(/品牌话题观察/g) || []).length).toBe(1); + + const openButton = container.querySelector( + 'button[aria-label="查看搜索结果 品牌话题观察"]', + ) as HTMLButtonElement | null; + expect(openButton).toBeTruthy(); + if (openButton) { + act(() => { + openButton.click(); + }); + } + + expect(container.textContent).toContain("搜索结果详情"); + expect(container.textContent).toContain("检索词:品牌 2026"); + expect(container.textContent).toContain("品牌讨论聚焦产品定位"); + + const citationLink = container.querySelector( + 'a[href="https://example.com/blog"]', + ) as HTMLAnchorElement | null; + expect(citationLink).toBeTruthy(); + + const backButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("返回列表"), + ); + expect(backButton).toBeTruthy(); + if (backButton) { + act(() => { + backButton.click(); + }); + } + + expect(container.textContent).toContain("搜索结果"); + expect(container.textContent).not.toContain("检索词:品牌 2026"); + }); + + it("搜索被阻塞时应展示原因并禁用提交", () => { + const { container } = renderSidebar({ + contextSearchQuery: "品牌", + contextSearchBlockedReason: "请先选择可用模型后再搜索", + }); + + expect(container.textContent).toContain("请先选择可用模型后再搜索"); + const submitButton = container.querySelector( + 'button[aria-label="提交上下文搜索"]', + ) as HTMLButtonElement | null; + expect(submitButton?.disabled).toBe(true); + }); + + it("应支持分支状态操作", () => { + const onSetBranchStatus = vi.fn(); + const { container } = renderSidebar({ + branchMode: "topic", + onSetBranchStatus, + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const mergeButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "采纳到主稿", + ); + expect(mergeButton).toBeTruthy(); + if (mergeButton) { + act(() => { + mergeButton.click(); + }); + } + expect(onSetBranchStatus).toHaveBeenCalledWith("topic-a", "merged"); + }); + + it("版本模式应展示产物版本语义", () => { + const onSetBranchStatus = vi.fn(); + const { container } = renderSidebar({ + branchMode: "version", + onSetBranchStatus, + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + expect(container.textContent).toContain("产物版本"); + expect(container.textContent).toContain("创建版本快照"); + + const setMainButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "设为主稿", + ); + expect(setMainButton).toBeTruthy(); + if (setMainButton) { + act(() => { + setMainButton.click(); + }); + } + expect(onSetBranchStatus).toHaveBeenCalledWith("topic-a", "merged"); + expect(container.querySelector("button[aria-label='删除分支']")).toBeNull(); + }); + + it("活动日志应展示后端闸门与运行标识", () => { + const { container } = renderSidebar(); + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + expect(container.textContent).toContain("闸门:写作闸门"); + expect(container.textContent).toContain("来源:skill"); + expect(container.textContent).toContain("运行:run-abcd…"); + }); + + it("活动日志应按运行维度分组展示步骤", () => { + const { container } = renderSidebar({ + activityLogs: [ + { + id: "log-run-1", + name: "research_topic", + status: "completed", + timeLabel: "10:20", + applyTarget: "主稿内容", + contextIds: ["material:1"], + runId: "rungrp01", + gateKey: "topic_select", + source: "skill", + artifactPaths: ["social-posts/research.md"], + inputSummary: "{\"topic\":\"AI\"}", + outputSummary: "已完成选题调研", + }, + { + id: "log-run-2", + name: "write_file", + status: "completed", + timeLabel: "10:21", + applyTarget: "主稿内容", + contextIds: ["material:1", "content:2"], + runId: "rungrp01", + gateKey: "write_mode", + source: "tool", + }, + ], + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + expect(container.textContent).toContain("research_topic"); + expect(container.textContent).toContain("write_file"); + expect(container.textContent).toContain("技能:research_topic"); + expect(container.textContent).toContain("修改:social-posts/research.md"); + expect(container.textContent).toContain("输入:{\"topic\":\"AI\"}"); + expect(container.textContent).toContain("输出:已完成选题调研"); + const runButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent === "运行:rungrp01", + ); + expect(runButtons.length).toBe(1); + }); + + it("点击运行标识应触发详情回调", () => { + const onViewRunDetail = vi.fn(); + const { container } = renderSidebar({ onViewRunDetail }); + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + const runButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("运行:run-abcd…"), + ); + expect(runButton).toBeTruthy(); + if (runButton) { + act(() => { + runButton.click(); + }); + } + + expect(onViewRunDetail).toHaveBeenCalledWith("run-abcdef123456"); + }); + + it("有选中运行详情时应展示详情卡片", () => { + const { container } = renderSidebar({ + activeRunDetail: { + id: "run-detail-1", + source: "skill", + source_ref: "social_post_with_cover", + session_id: "session-1", + status: "running", + started_at: "2026-03-06T01:02:03Z", + finished_at: null, + duration_ms: null, + error_code: null, + error_message: null, + metadata: JSON.stringify({ gate_key: "write_mode" }), + created_at: "2026-03-06T01:02:03Z", + updated_at: "2026-03-06T01:02:04Z", + }, + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + expect(container.textContent).toContain("运行详情"); + expect(container.textContent).toContain("ID:run-detail-1"); + expect(container.textContent).toContain("状态:运行中"); + }); + + it("运行详情应支持复制运行ID与元数据", async () => { + const { container } = renderSidebar({ + activeRunDetail: { + id: "run-copy-1", + source: "skill", + source_ref: "social_post_with_cover", + session_id: "session-copy", + status: "success", + started_at: "2026-03-06T01:02:03Z", + finished_at: "2026-03-06T01:02:06Z", + duration_ms: 3000, + error_code: null, + error_message: null, + metadata: JSON.stringify({ gate_key: "write_mode", foo: "bar" }), + created_at: "2026-03-06T01:02:03Z", + updated_at: "2026-03-06T01:02:06Z", + }, + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + const copyIdButton = container.querySelector( + "button[aria-label='复制运行ID']", + ) as HTMLButtonElement | null; + const copyMetadataButton = container.querySelector( + "button[aria-label='复制运行元数据']", + ) as HTMLButtonElement | null; + + expect(copyIdButton).toBeTruthy(); + expect(copyMetadataButton).toBeTruthy(); + + if (copyIdButton) { + act(() => { + copyIdButton.click(); + }); + } + + if (copyMetadataButton) { + act(() => { + copyMetadataButton.click(); + }); + } + + expect(mockWriteClipboardText).toHaveBeenNthCalledWith(1, "run-copy-1"); + expect(mockWriteClipboardText).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('"gate_key": "write_mode"'), + ); + }); + + it("运行详情应展示阶段与产物路径,并支持复制产物路径", () => { + const { container } = renderSidebar({ + activeRunDetail: { + id: "run-artifact-1", + source: "skill", + source_ref: "social_post_with_cover", + session_id: "session-artifact", + status: "success", + started_at: "2026-03-06T02:00:03Z", + finished_at: "2026-03-06T02:00:08Z", + duration_ms: 5000, + error_code: null, + error_message: null, + metadata: JSON.stringify({ + workflow: "social_content_pipeline_v1", + execution_id: "exec-artifact-1", + version_id: "ver-artifact-1", + stages: ["topic_select", "write_mode", "publish_confirm"], + artifact_paths: [ + "social-posts/demo.md", + "social-posts/demo.publish-pack.json", + ], + }), + created_at: "2026-03-06T02:00:03Z", + updated_at: "2026-03-06T02:00:08Z", + }, + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + expect(container.textContent).toContain("工作流:social_content_pipeline_v1"); + expect(container.textContent).toContain("执行ID:exec-artifact-1"); + expect(container.textContent).toContain("版本ID:ver-artifact-1"); + expect(container.textContent).toContain("阶段:选题闸门 → 写作闸门 → 发布闸门"); + expect(container.textContent).toContain("social-posts/demo.md"); + expect(container.textContent).toContain("social-posts/demo.publish-pack.json"); + + const copyArtifactButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button + .getAttribute("aria-label") + ?.startsWith("复制产物路径-social-posts/demo.md"), + ); + expect(copyArtifactButton).toBeTruthy(); + if (copyArtifactButton) { + act(() => { + copyArtifactButton.click(); + }); + } + + expect(mockWriteClipboardText).toHaveBeenCalledWith("social-posts/demo.md"); + + const revealArtifactButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button + .getAttribute("aria-label") + ?.startsWith("定位产物路径-social-posts/demo.md"), + ); + expect(revealArtifactButton).toBeTruthy(); + if (revealArtifactButton) { + act(() => { + revealArtifactButton.click(); + }); + } + expect(mockRevealSessionFileInFinder).toHaveBeenCalledWith( + "session-artifact", + "social-posts/demo.md", + ); + + const openArtifactButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button + .getAttribute("aria-label") + ?.startsWith("打开产物路径-social-posts/demo.md"), + ); + expect(openArtifactButton).toBeTruthy(); + if (openArtifactButton) { + act(() => { + openArtifactButton.click(); + }); + } + expect(mockOpenSessionFileWithDefaultApp).toHaveBeenCalledWith( + "session-artifact", + "social-posts/demo.md", + ); + }); + + it("活动日志分组应支持直接定位与打开产物", () => { + const { container } = renderSidebar({ + activityLogs: [ + { + id: "log-run-artifact-1", + name: "social_post_with_cover", + status: "completed", + timeLabel: "11:20", + applyTarget: "主稿内容", + contextIds: ["material:1"], + runId: "run-artifact-group-1", + executionId: "exec-artifact-group-1", + sessionId: "session-group", + artifactPaths: ["social-posts/group.md"], + gateKey: "write_mode", + source: "skill", + }, + ], + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + const revealArtifactButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button + .getAttribute("aria-label") + ?.startsWith("定位活动产物路径-social-posts/group.md"), + ); + expect(revealArtifactButton).toBeTruthy(); + if (revealArtifactButton) { + act(() => { + revealArtifactButton.click(); + }); + } + expect(mockRevealSessionFileInFinder).toHaveBeenCalledWith( + "session-group", + "social-posts/group.md", + ); + + const openArtifactButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button + .getAttribute("aria-label") + ?.startsWith("打开活动产物路径-social-posts/group.md"), + ); + expect(openArtifactButton).toBeTruthy(); + if (openArtifactButton) { + act(() => { + openArtifactButton.click(); + }); + } + expect(mockOpenSessionFileWithDefaultApp).toHaveBeenCalledWith( + "session-group", + "social-posts/group.md", + ); + }); + + it("任务提交面板应按任务类型分组展示并支持复制路径", () => { + const { container } = renderSidebar({ + creationTaskEvents: [ + { + taskId: "task-image-1", + taskType: "image_generate", + path: ".proxycast/tasks/image_generate/a.json", + absolutePath: "/tmp/proxycast/.proxycast/tasks/image_generate/a.json", + createdAt: Date.parse("2026-03-06T02:20:00Z"), + timeLabel: "10:20", + }, + { + taskId: "task-image-2", + taskType: "image_generate", + path: ".proxycast/tasks/image_generate/b.json", + createdAt: Date.parse("2026-03-06T02:21:00Z"), + timeLabel: "10:21", + }, + { + taskId: "task-typesetting-1", + taskType: "typesetting", + path: ".proxycast/tasks/typesetting/c.json", + createdAt: Date.parse("2026-03-06T02:22:00Z"), + timeLabel: "10:22", + }, + ], + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + expect(container.textContent).toContain("任务提交"); + expect(container.textContent).toContain("配图生成"); + expect(container.textContent).toContain("排版优化"); + expect(container.textContent).toContain("本组 2 条"); + + const copyAbsolutePathButton = container.querySelector( + 'button[aria-label="复制任务文件绝对路径-task-image-1"]', + ) as HTMLButtonElement | null; + expect(copyAbsolutePathButton).toBeTruthy(); + if (copyAbsolutePathButton) { + act(() => { + copyAbsolutePathButton.click(); + }); + } + + expect(mockWriteClipboardText).toHaveBeenCalledWith( + "/tmp/proxycast/.proxycast/tasks/image_generate/a.json", + ); + }); + + it("定位产物失败时应透传后端错误信息", async () => { + mockRevealSessionFileInFinder.mockRejectedValueOnce(new Error("文件不存在")); + const { container } = renderSidebar({ + activeRunDetail: { + id: "run-error-path", + source: "skill", + source_ref: "social_post_with_cover", + session_id: "session-error", + status: "success", + started_at: "2026-03-06T02:10:03Z", + finished_at: "2026-03-06T02:10:08Z", + duration_ms: 5000, + error_code: null, + error_message: null, + metadata: JSON.stringify({ + artifact_paths: ["social-posts/error.md"], + }), + created_at: "2026-03-06T02:10:03Z", + updated_at: "2026-03-06T02:10:08Z", + }, + }); + + const workflowTab = container.querySelector( + 'button[aria-label="打开编排工作台"]', + ) as HTMLButtonElement | null; + if (workflowTab) { + act(() => { + workflowTab.click(); + }); + } + + const activityToggle = container.querySelector( + "button[aria-label='切换活动日志']", + ) as HTMLButtonElement | null; + if (activityToggle) { + act(() => { + activityToggle.click(); + }); + } + + const revealArtifactButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button + .getAttribute("aria-label") + ?.startsWith("定位产物路径-social-posts/error.md"), + ); + expect(revealArtifactButton).toBeTruthy(); + if (revealArtifactButton) { + await act(async () => { + revealArtifactButton.click(); + await Promise.resolve(); + }); + } + + expect(mockToastError).toHaveBeenCalledWith( + expect.stringContaining("文件不存在"), + ); + }); +}); diff --git a/src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx b/src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx new file mode 100644 index 000000000..c0edc89fb --- /dev/null +++ b/src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx @@ -0,0 +1,2880 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowLeft, + ArrowRight, + Check, + CheckCircle2, + ChevronDown, + ChevronLeft, + ChevronRight, + Circle, + Clock3, + ExternalLink, + FileText, + FileUp, + GitBranch, + Globe, + Image as ImageIcon, + Link2, + Loader2, + PencilLine, + Plus, + Search, + Share2, + Trash2, + X, +} from "lucide-react"; +import styled from "styled-components"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import type { StepStatus } from "@/components/content-creator/types"; +import type { TopicBranchItem, TopicBranchStatus } from "../hooks/useTopicBranchBoard"; +import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace"; +import type { AgentRun } from "@/lib/api/executionRun"; +import { + openFileWithDefaultApp as openSessionFileWithDefaultApp, + revealFileInFinder as revealSessionFileInFinder, +} from "@/lib/api/session-files"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "sonner"; +import { logRenderPerf } from "@/lib/perfDebug"; + +const SidebarContainer = styled.aside` + display: flex; + flex-direction: column; + width: 290px; + min-width: 290px; + height: 100%; + border-right: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.24); + position: relative; +`; + +const SidebarCollapseHandle = styled.button` + position: absolute; + right: -10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 60px; + border: 1px solid hsl(var(--border)); + border-left: 0; + border-radius: 0 10px 10px 0; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2; + + &:hover { + color: hsl(var(--foreground)); + background: hsl(var(--accent) / 0.5); + } +`; + + +const SidebarHeader = styled.div` + padding: 16px 16px 14px; + border-bottom: 1px solid hsl(var(--border) / 0.7); + background: hsl(var(--background) / 0.9); + backdrop-filter: blur(10px); +`; + +const SidebarEyebrow = styled.div` + font-size: 10px; + color: hsl(var(--muted-foreground)); + letter-spacing: 0.04em; + text-transform: uppercase; + font-weight: 600; +`; + +const SidebarTitle = styled.div` + margin-top: 6px; + font-size: 16px; + line-height: 1.3; + font-weight: 700; + color: hsl(var(--foreground)); +`; + +const SidebarDescription = styled.div` + margin-top: 6px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +`; + +const SidebarTabs = styled.div` + margin-top: 14px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +`; + +const SidebarTabButton = styled.button<{ $active: boolean }>` + height: 36px; + border-radius: 10px; + border: 1px solid + ${(props) => + props.$active ? 'hsl(var(--primary) / 0.45)' : 'hsl(var(--border))'}; + background: ${(props) => + props.$active ? 'hsl(var(--primary) / 0.10)' : 'hsl(var(--background))'}; + color: ${(props) => + props.$active ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))'}; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 0 9px; + font-size: 11px; + line-height: 1.2; + font-weight: 600; + + &:hover { + border-color: hsl(var(--primary) / 0.45); + color: hsl(var(--foreground)); + } +`; + +const SidebarTabCount = styled.span<{ $active: boolean }>` + min-width: 15px; + height: 15px; + border-radius: 999px; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 9px; + background: ${(props) => + props.$active ? 'hsl(var(--primary))' : 'hsl(var(--muted))'}; + color: ${(props) => + props.$active ? 'hsl(var(--primary-foreground))' : 'hsl(var(--muted-foreground))'}; +`; + +const SidebarBody = styled.div` + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: visible; +`; + +const SectionBadge = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 6px; + border-radius: 999px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-size: 10px; + font-weight: 600; +`; + +const Section = styled.section<{ $allowOverflow?: boolean }>` + padding: 14px 16px; + border-bottom: 1px solid hsl(var(--border) / 0.7); + ${(props) => props.$allowOverflow && ` + position: relative; + z-index: 10; + `} +`; + +const SectionTitle = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + margin-bottom: 10px; +`; + +const NewTopicButton = styled.button` + width: 100%; + height: 38px; + border-radius: 10px; + border: 1px dashed hsl(var(--border)); + display: flex; + align-items: center; + gap: 7px; + padding: 0 12px; + font-size: 12px; + color: hsl(var(--foreground)); + background: hsl(var(--background)); + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary) / 0.5); + background: hsl(var(--accent) / 0.6); + } +`; + +const ProgressText = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +`; + +const ProgressBar = styled.div` + height: 7px; + border-radius: 999px; + background: hsl(var(--muted)); + overflow: hidden; + margin-top: 8px; +`; + +const ProgressFill = styled.div<{ $percent: number }>` + width: ${(props) => Math.max(0, Math.min(100, props.$percent))}%; + height: 100%; + background: hsl(var(--primary)); + transition: width 0.2s ease; +`; + +const StepList = styled.div` + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 5px; +`; + +const StepRow = styled.div<{ $status: StepStatus }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + line-height: 1.5; + color: ${(props) => + props.$status === "completed" + ? "hsl(var(--foreground))" + : "hsl(var(--muted-foreground))"}; +`; + +const AddContextButton = styled.button` + width: 100%; + height: 36px; + border-radius: 10px; + border: 1px dashed hsl(var(--border)); + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; + font-size: 12px; + color: hsl(var(--foreground)); + background: hsl(var(--background)); + + &:hover { + border-color: hsl(var(--primary) / 0.5); + background: hsl(var(--accent) / 0.5); + } +`; + +const ContextModalOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 70; + background: hsl(220 20% 10% / 0.46); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +`; + +const ContextModalCard = styled.div` + width: min(500px, calc(100vw - 48px)); + border-radius: 20px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + box-shadow: 0 24px 48px hsl(220 35% 8% / 0.22); + overflow: hidden; +`; + +const ContextModalHeader = styled.div` + height: 66px; + padding: 0 20px; + border-bottom: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; +`; + +const ContextModalTitle = styled.h3` + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 1; + color: hsl(var(--foreground)); +`; + +const ContextModalTitleCentered = styled(ContextModalTitle)` + flex: 1; + text-align: center; + font-size: 20px; +`; + +const ContextModalHeaderActions = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; +`; + +const ContextModalHeaderButton = styled.button` + width: 24px; + height: 24px; + border: 0; + border-radius: 999px; + background: transparent; + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + background: hsl(var(--accent) / 0.5); + } +`; + +const ContextModalBody = styled.div` + padding: 14px 16px 16px; +`; + +const ContextDropArea = styled.div<{ $dragging?: boolean }>` + border: 1px dashed + ${(props) => + props.$dragging ? "hsl(var(--primary) / 0.55)" : "hsl(var(--border))"}; + border-radius: 14px; + min-height: 186px; + padding: 16px 12px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + background: ${(props) => + props.$dragging ? "hsl(var(--primary) / 0.06)" : "hsl(var(--muted) / 0.06)"}; + transition: border-color 0.2s ease, background 0.2s ease; +`; + +const ContextDropHint = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const ContextModalActionGrid = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; +`; + +const ContextModalActionButton = styled.button` + height: 34px; + border-radius: 999px; + border: 1px solid hsl(var(--border)); + padding: 0 14px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary) / 0.45); + background: hsl(var(--accent) / 0.5); + } +`; + +const ContextModalErrorText = styled.div` + margin-top: 12px; + font-size: 12px; + line-height: 1.45; + color: hsl(var(--destructive)); + text-align: center; +`; + +const ContextTextarea = styled.textarea` + width: 100%; + min-height: 228px; + border-radius: 18px; + border: 2px solid hsl(var(--foreground) / 0.58); + background: hsl(var(--background)); + padding: 14px 12px; + resize: none; + font-size: 13px; + line-height: 1.5; + color: hsl(var(--foreground)); + + &::placeholder { + color: hsl(var(--muted-foreground)); + } + + &:focus { + outline: none; + border-color: hsl(var(--primary) / 0.45); + } +`; + +const ContextLinkInput = styled.input` + width: 100%; + height: 42px; + border-radius: 12px; + border: 2px solid hsl(var(--foreground) / 0.55); + background: hsl(var(--background)); + padding: 0 12px; + font-size: 13px; + color: hsl(var(--foreground)); + + &::placeholder { + color: hsl(var(--muted-foreground)); + } + + &:focus { + outline: none; + border-color: hsl(var(--primary) / 0.45); + } +`; + +const ContextModalFooter = styled.div` + margin-top: 12px; + display: flex; + justify-content: flex-end; +`; + +const ContextConfirmButton = styled.button<{ $disabled?: boolean }>` + min-width: 86px; + height: 38px; + border: 0; + border-radius: 999px; + padding: 0 16px; + background: ${(props) => + props.$disabled ? "hsl(var(--muted))" : "hsl(217 24% 90%)"}; + color: ${(props) => + props.$disabled ? "hsl(var(--muted-foreground))" : "hsl(218 20% 42%)"}; + font-size: 20px; + font-weight: 600; + line-height: 1; + cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")}; + + &:hover { + opacity: ${(props) => (props.$disabled ? 1 : 0.92)}; + } +`; + +const ContextSearchCard = styled.div` + margin-top: 10px; + border: 1px solid hsl(var(--border)); + border-radius: 14px; + background: hsl(var(--background)); + padding: 12px; +`; + +const SearchInputWrap = styled.div` + position: relative; +`; + +const SearchInput = styled.input` + width: 100%; + height: 28px; + border: 0; + padding: 0 0 0 28px; + font-size: 13px; + line-height: 1.5; + background: transparent; + color: hsl(var(--foreground)); + + &:focus { + outline: none; + } + + &::placeholder { + color: hsl(var(--muted-foreground)); + } +`; + +const SearchIcon = styled(Search)` + position: absolute; + left: 0; + top: 5px; + color: hsl(var(--muted-foreground)); +`; + +const SearchActionRow = styled.div` + margin-top: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +`; + +const SearchModeTrigger = styled.button` + min-width: 90px; + height: 32px; + border-radius: 999px; + border: 1px solid hsl(var(--border)); + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 12px; + font-size: 12px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + + &:hover { + border-color: hsl(var(--primary) / 0.45); + background: hsl(var(--accent) / 0.45); + } +`; + +const SearchModeMenuRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + width: 100%; +`; + +const SearchModeMenuCheck = styled.span` + margin-left: auto; + color: hsl(var(--primary)); +`; + +const SearchSubmitButton = styled.button<{ $disabled: boolean }>` + width: 28px; + height: 28px; + border-radius: 999px; + border: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${(props) => + props.$disabled ? "hsl(var(--muted))" : "hsl(var(--secondary))"}; + color: ${(props) => + props.$disabled ? "hsl(var(--muted-foreground))" : "hsl(var(--primary))"}; + cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")}; + + &:hover { + transform: ${(props) => (props.$disabled ? "none" : "scale(1.03)")}; + } + + .spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +`; + +const SearchHintText = styled.div<{ $error?: boolean }>` + margin-top: 10px; + font-size: 11px; + line-height: 1.5; + color: ${(props) => + props.$error ? "hsl(var(--destructive))" : "hsl(var(--muted-foreground))"}; +`; + +const ContextQuery = styled.div` + margin-top: 8px; + font-size: 11px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +`; + +const CompactContextList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const CompactContextRow = styled.div<{ $interactive?: boolean; $active?: boolean }>` + display: flex; + align-items: center; + gap: 10px; + border: 1px solid ${(props) => + props.$active ? 'hsl(var(--primary) / 0.3)' : 'hsl(var(--border))'}; + border-radius: 10px; + padding: 12px 14px; + background: ${(props) => + props.$active ? 'hsl(var(--primary) / 0.05)' : 'hsl(var(--background))'}; + cursor: ${(props) => (props.$interactive ? 'pointer' : 'default')}; + transition: all 0.15s ease; + + &:hover { + border-color: ${(props) => + props.$interactive ? 'hsl(var(--primary) / 0.45)' : 'hsl(var(--border))'}; + background: ${(props) => + props.$interactive ? 'hsl(var(--accent) / 0.35)' : 'hsl(var(--background))'}; + } +`; + +const CompactContextOpenButton = styled.button` + flex: 1; + min-width: 0; + border: 0; + background: transparent; + padding: 0; + display: flex; + align-items: center; + gap: 8px; + text-align: left; + color: inherit; + cursor: inherit; +`; + +const CompactContextInfo = styled.div` + min-width: 0; + flex: 1; +`; + +const CompactContextName = styled.div` + font-size: 13px; + line-height: 1.4; + font-weight: 500; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const CompactContextMeta = styled.div` + margin-top: 4px; + font-size: 11px; + line-height: 1.3; + color: hsl(var(--muted-foreground) / 0.85); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const SearchResultIconWrap = styled.div` + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid hsl(var(--border)); + display: inline-flex; + align-items: center; + justify-content: center; + color: hsl(var(--primary)); + flex-shrink: 0; +`; + +const CompactContextCheckbox = styled.input.attrs({ type: 'checkbox' })` + width: 16px; + height: 16px; + border-radius: 4px; + border: 1.5px solid hsl(var(--border)); + background: hsl(var(--background)); + cursor: pointer; + flex-shrink: 0; + appearance: none; + position: relative; + transition: all 0.15s ease; + + &:hover { + border-color: hsl(var(--primary) / 0.6); + } + + &:checked { + background: hsl(var(--primary)); + border-color: hsl(var(--primary)); + } + + &:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } +`; + +const DetailTopBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +`; + +const DetailBackButton = styled.button` + border: 0; + background: transparent; + padding: 0; + display: inline-flex; + align-items: center; + gap: 3px; + line-height: 1.2; + color: hsl(var(--muted-foreground)); + cursor: pointer; + font-size: 11px; + + &:hover { + color: hsl(var(--foreground)); + } +`; + +const DetailCard = styled.div` + border: 1px solid hsl(var(--border)); + border-radius: 14px; + background: hsl(var(--background)); + padding: 12px; +`; + +const DetailTitle = styled.div` + font-size: 18px; + font-weight: 700; + color: hsl(var(--foreground)); + line-height: 1.35; +`; + +const DetailMeta = styled.div` + margin-top: 8px; + font-size: 11px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +`; + +const DetailSection = styled.div` + margin-top: 12px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--muted) / 0.22); + padding: 10px; +`; + +const DetailSectionLabel = styled.div` + font-size: 11px; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const DetailBody = styled.div` + margin-top: 10px; + font-size: 13px; + line-height: 1.75; + color: hsl(var(--foreground)); + white-space: pre-wrap; +`; + +const DetailSourceList = styled.div` + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +const DetailSourceItem = styled.a` + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: hsl(var(--primary)); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; + +const BranchList = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const BranchItem = styled.div<{ $active: boolean }>` + border: 1px solid + ${(props) => + props.$active ? "hsl(var(--primary) / 0.45)" : "hsl(var(--border))"}; + border-radius: 8px; + padding: 7px; + background: ${(props) => + props.$active ? "hsl(var(--primary) / 0.08)" : "hsl(var(--background))"}; +`; + +const BranchHead = styled.div` + display: flex; + align-items: center; + gap: 5px; +`; + +const BranchTitleButton = styled.button` + border: 0; + background: transparent; + padding: 0; + margin: 0; + flex: 1; + text-align: left; + font-size: 11px; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +`; + +const StatusBadge = styled.span<{ $status: TopicBranchStatus }>` + font-size: 10px; + padding: 2px 6px; + border-radius: 999px; + color: ${(props) => + props.$status === "merged" + ? "hsl(142 76% 30%)" + : props.$status === "in_progress" + ? "hsl(var(--primary))" + : "hsl(var(--muted-foreground))"}; + background: ${(props) => + props.$status === "merged" + ? "hsl(142 76% 90%)" + : props.$status === "in_progress" + ? "hsl(var(--primary) / 0.16)" + : "hsl(var(--muted))"}; +`; + +const ActionRow = styled.div` + margin-top: 6px; + display: flex; + gap: 5px; +`; + +const TinyButton = styled.button` + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + border-radius: 6px; + font-size: 11px; + color: hsl(var(--foreground)); + padding: 3px 7px; + + &:hover { + border-color: hsl(var(--primary) / 0.5); + } +`; + +const DeleteButton = styled.button` + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + padding: 2px; + border-radius: 4px; + cursor: pointer; + + &:hover { + color: hsl(var(--destructive)); + background: hsl(var(--destructive) / 0.12); + } +`; + +const ActivityList = styled.div` + display: flex; + flex-direction: column; + gap: 5px; +`; + +const ActivityItem = styled.div` + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + border-radius: 8px; + padding: 6px 7px; + font-size: 11px; +`; + +const ActivityGroupHeader = styled.div` + display: flex; + align-items: center; + gap: 6px; + color: hsl(var(--foreground)); +`; + +const ActivityTitle = styled.div` + display: flex; + align-items: center; + gap: 6px; + color: hsl(var(--foreground)); +`; + +const ActivityMeta = styled.div` + margin-top: 8px; + padding: 8px 12px; + font-size: 11px; + color: hsl(var(--muted-foreground)); + line-height: 1.6; + background: hsl(var(--muted) / 0.3); + border-radius: 8px; +`; + +const ActivityStepList = styled.div` + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const ActivityStepItem = styled.div` + border: 1px solid hsl(var(--border) / 0.8); + border-radius: 6px; + background: hsl(var(--muted) / 0.24); + padding: 5px 6px; +`; + +const RunLinkButton = styled.button` + border: 0; + background: transparent; + padding: 0; + margin: 0; + color: hsl(var(--primary)); + cursor: pointer; + font-size: 11px; + line-height: 1.35; + + &:disabled { + color: hsl(var(--muted-foreground)); + cursor: default; + } +`; + +const RunDetailPanel = styled.div` + margin-top: 8px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + padding: 8px; +`; + +const RunDetailTitle = styled.div` + font-size: 11px; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 6px; +`; + +const RunDetailRow = styled.div` + font-size: 11px; + color: hsl(var(--muted-foreground)); + line-height: 1.45; + word-break: break-all; +`; + +const RunDetailArtifacts = styled.div` + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const RunDetailArtifactRow = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const RunDetailArtifactPath = styled.code` + flex: 1; + min-width: 0; + font-size: 10px; + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 0.4); + border-radius: 6px; + padding: 2px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const RunDetailCode = styled.pre` + margin-top: 6px; + font-size: 10px; + line-height: 1.4; + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 0.5); + border-radius: 6px; + padding: 6px; + max-height: 120px; + overflow: auto; +`; + +const RunDetailActions = styled.div` + margin-top: 6px; + display: flex; + gap: 6px; +`; + +const RunDetailActionButton = styled.button` + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + border-radius: 6px; + font-size: 11px; + color: hsl(var(--foreground)); + padding: 3px 7px; + cursor: pointer; + + &:disabled { + color: hsl(var(--muted-foreground)); + cursor: default; + } +`; + +function getStepIcon(status: StepStatus) { + if (status === "completed") { + return ; + } + if (status === "active") { + return ; + } + return ; +} + +function getBranchStatusText(status: TopicBranchStatus): string { + if (status === "in_progress") return "进行中"; + if (status === "pending") return "待评审"; + if (status === "merged") return "已合并"; + return "备选"; +} + +function formatGateLabel( + gateKey?: SidebarActivityLog["gateKey"], +): string | null { + if (!gateKey || gateKey === "idle") { + return null; + } + if (gateKey === "topic_select") { + return "选题闸门"; + } + if (gateKey === "write_mode") { + return "写作闸门"; + } + if (gateKey === "publish_confirm") { + return "发布闸门"; + } + return null; +} + +function formatRunIdShort(runId?: string): string | null { + const trimmed = runId?.trim(); + if (!trimmed) { + return null; + } + if (trimmed.length <= 8) { + return trimmed; + } + return `${trimmed.slice(0, 8)}…`; +} + +function formatRunStatusLabel(status: AgentRun["status"]): string { + if (status === "queued") return "排队中"; + if (status === "running") return "运行中"; + if (status === "success") return "成功"; + if (status === "error") return "失败"; + if (status === "canceled") return "已取消"; + if (status === "timeout") return "超时"; + return status; +} + +function formatContextCreatedAt(createdAt?: number): string | null { + if (!createdAt || !Number.isFinite(createdAt)) { + return null; + } + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toLocaleString([], { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatRunMetadata(raw: string | null): string { + if (!raw || !raw.trim()) { + return "-"; + } + try { + const parsed = JSON.parse(raw); + return JSON.stringify(parsed, null, 2); + } catch { + return raw; + } +} + +function normalizeArtifactPaths(raw?: string[]): string[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .map((path) => path.trim()) + .filter((path) => path.length > 0); +} + +function mergeArtifactPaths(current: string[], incoming?: string[]): string[] { + const next = normalizeArtifactPaths(incoming); + if (next.length === 0) { + return current; + } + const merged = new Set(current); + next.forEach((path) => merged.add(path)); + return Array.from(merged); +} + +function formatActionErrorMessage(prefix: string, error: unknown): string { + const candidates: string[] = []; + if (typeof error === "string") { + candidates.push(error); + } + if (error instanceof Error && error.message.trim()) { + candidates.push(error.message); + } + if (typeof error === "object" && error !== null && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) { + candidates.push(message); + } + } + + const detail = candidates + .map((item) => item.trim()) + .find((item) => item.length > 0); + if (!detail) { + return prefix; + } + if (detail === prefix || detail.startsWith(`${prefix}:`)) { + return detail; + } + return `${prefix}:${detail}`; +} + +interface ParsedRunMetadataSummary { + workflow: string | null; + executionId: string | null; + versionId: string | null; + stages: string[]; + artifactPaths: string[]; +} + +function parseRunMetadataSummary(raw: string | null): ParsedRunMetadataSummary { + const fallback: ParsedRunMetadataSummary = { + workflow: null, + executionId: null, + versionId: null, + stages: [], + artifactPaths: [], + }; + if (!raw || !raw.trim()) { + return fallback; + } + + try { + const parsed = JSON.parse(raw) as Record; + const readString = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; + }; + const readStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item) => item.length > 0); + }; + + return { + workflow: readString(parsed.workflow), + executionId: readString(parsed.execution_id), + versionId: readString(parsed.version_id), + stages: readStringArray(parsed.stages), + artifactPaths: readStringArray(parsed.artifact_paths), + }; + } catch { + return fallback; + } +} + +function formatStageLabelByKey(raw: string): string { + if (raw === "topic_select") { + return "选题闸门"; + } + if (raw === "write_mode") { + return "写作闸门"; + } + if (raw === "publish_confirm") { + return "发布闸门"; + } + return raw; +} + +async function writeClipboardText(text: string): Promise { + const value = text.trim(); + if (!value) { + return; + } + const clipboard = navigator?.clipboard; + if (!clipboard?.writeText) { + return; + } + await clipboard.writeText(value); +} + +type BranchMode = "topic" | "version"; +type SidebarTab = "context" | "workflow"; +type ActivityStatus = SidebarActivityLog["status"]; + +export interface ThemeWorkbenchCreationTaskEvent { + taskId: string; + taskType: string; + path: string; + absolutePath?: string; + createdAt: number; + timeLabel: string; +} + +interface ActivityLogGroup { + key: string; + runId?: string; + sessionId?: string; + messageId?: string; + status: ActivityStatus; + source?: string; + gateKey?: SidebarActivityLog["gateKey"]; + timeLabel: string; + artifactPaths: string[]; + logs: SidebarActivityLog[]; +} + +interface CreationTaskGroup { + key: string; + taskType: string; + label: string; + latestTimeLabel: string; + tasks: ThemeWorkbenchCreationTaskEvent[]; +} + +function mergeActivityStatus( + previous: ActivityStatus, + next: ActivityStatus, +): ActivityStatus { + if (previous === "running" || next === "running") { + return "running"; + } + if (previous === "failed" || next === "failed") { + return "failed"; + } + return "completed"; +} + +function resolveActivityGroupKey(log: SidebarActivityLog): { + key: string; + runId?: string; + messageId?: string; +} { + const normalizedRunId = log.runId?.trim(); + if (normalizedRunId) { + return { + key: `run:${normalizedRunId}`, + runId: normalizedRunId, + }; + } + + const normalizedMessageId = log.messageId?.trim(); + if (normalizedMessageId) { + return { + key: `message:${normalizedMessageId}`, + messageId: normalizedMessageId, + }; + } + + return { + key: `orphan:${log.id}`, + }; +} + +function resolveActivityMarker(status: ActivityStatus): string { + if (status === "completed") { + return "✓"; + } + if (status === "failed") { + return "✕"; + } + return "●"; +} + +function formatLogActionLabel(log: SidebarActivityLog): string { + const normalizedSource = log.source?.trim().toLowerCase(); + if (normalizedSource === "skill") { + return `技能:${log.name}`; + } + return `动作:${log.name}`; +} + +function formatArtifactPathsLabel(paths?: string[]): string { + if (!paths || paths.length === 0) { + return "主稿内容"; + } + if (paths.length === 1) { + return paths[0]; + } + return `${paths[0]} 等 ${paths.length} 个产物`; +} + +function formatCreationTaskTypeLabel(taskType: string): string { + const normalized = taskType.trim().toLowerCase(); + if (normalized === "video_generate") { + return "视频生成"; + } + if (normalized === "broadcast_generate") { + return "播客整理"; + } + if (normalized === "cover_generate") { + return "封面生成"; + } + if (normalized === "modal_resource_search") { + return "资源检索"; + } + if (normalized === "image_generate") { + return "配图生成"; + } + if (normalized === "url_parse") { + return "链接解析"; + } + if (normalized === "typesetting") { + return "排版优化"; + } + return taskType.trim() || "未分类任务"; +} + +function resolveContextSourceSubLabel( + source: "material" | "content" | "search", + searchMode?: "web" | "social", +): string { + if (source === "material") { + return "素材库"; + } + if (source === "content") { + return "历史内容"; + } + return searchMode === "social" ? "社交媒体" : "网络搜索"; +} + +function resolveFileNameFromPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const segments = normalized.split("/"); + return segments[segments.length - 1] || "上下文文件"; +} + +interface ThemeWorkbenchSidebarProps { + branchMode?: BranchMode; + onNewTopic: () => void; + onSwitchTopic: (topicId: string) => void; + onDeleteTopic: (topicId: string) => void; + branchItems: TopicBranchItem[]; + onSetBranchStatus: (topicId: string, status: TopicBranchStatus) => void; + workflowSteps: Array<{ id: string; title: string; status: StepStatus }>; + contextSearchQuery: string; + onContextSearchQueryChange: (value: string) => void; + contextSearchMode: "web" | "social"; + onContextSearchModeChange: (value: "web" | "social") => void; + contextSearchLoading: boolean; + contextSearchError?: string | null; + contextSearchBlockedReason?: string | null; + onSubmitContextSearch: () => Promise | void; + onAddTextContext?: (payload: { + content: string; + name?: string; + }) => Promise | void; + onAddLinkContext?: (payload: { + url: string; + name?: string; + }) => Promise | void; + onAddFileContext?: (payload: { + path: string; + name?: string; + }) => Promise | void; + onAddImage?: () => Promise | void; + onImportDocument?: () => Promise | void; + contextItems: Array<{ + id: string; + name: string; + source: "material" | "content" | "search"; + searchMode?: "web" | "social"; + query?: string; + previewText?: string; + citations?: Array<{ title: string; url: string }>; + createdAt?: number; + active: boolean; + }>; + onToggleContextActive: (contextId: string) => void; + onViewContextDetail?: (contextId: string) => void; + contextBudget: { + activeCount: number; + activeCountLimit: number; + estimatedTokens: number; + tokenLimit: number; + }; + activityLogs: SidebarActivityLog[]; + creationTaskEvents?: ThemeWorkbenchCreationTaskEvent[]; + onViewRunDetail?: (runId: string) => void; + activeRunDetail?: AgentRun | null; + activeRunDetailLoading?: boolean; + onRequestCollapse?: () => void; +} + +function ThemeWorkbenchSidebarComponent({ + branchMode = "version", + onNewTopic, + onSwitchTopic, + onDeleteTopic, + branchItems, + onSetBranchStatus, + workflowSteps, + contextSearchQuery, + onContextSearchQueryChange, + contextSearchMode, + onContextSearchModeChange, + contextSearchLoading, + contextSearchError, + contextSearchBlockedReason, + onSubmitContextSearch, + onAddTextContext, + onAddLinkContext, + onAddFileContext, + onAddImage, + onImportDocument, + contextItems, + onToggleContextActive, + onViewContextDetail, + contextBudget, + activityLogs, + creationTaskEvents = [], + onViewRunDetail, + activeRunDetail, + activeRunDetailLoading = false, + onRequestCollapse, +}: ThemeWorkbenchSidebarProps) { + const [showActivityLogs, setShowActivityLogs] = useState(false); + const [showCreationTasks, setShowCreationTasks] = useState(true); + const [activeTab, setActiveTab] = useState("context"); + const [selectedSearchResultId, setSelectedSearchResultId] = useState(null); + const renderCountRef = useRef(0); + const lastCommitAtRef = useRef(null); + renderCountRef.current += 1; + const currentRenderCount = renderCountRef.current; + const isVersionMode = branchMode === "version"; + const completedSteps = useMemo( + () => workflowSteps.filter((step) => step.status === "completed").length, + [workflowSteps], + ); + const progressPercent = + workflowSteps.length > 0 ? (completedSteps / workflowSteps.length) * 100 : 0; + const runMetadataText = useMemo( + () => formatRunMetadata(activeRunDetail?.metadata ?? null), + [activeRunDetail?.metadata], + ); + const runMetadataSummary = useMemo( + () => parseRunMetadataSummary(activeRunDetail?.metadata ?? null), + [activeRunDetail?.metadata], + ); + const runDetailSessionId = activeRunDetail?.session_id?.trim() || null; + const handleRevealArtifactInFinder = useCallback( + async (artifactPath: string, sessionId?: string | null) => { + const resolvedSessionId = sessionId?.trim() || runDetailSessionId; + if (!resolvedSessionId) { + toast.error("缺少会话ID,无法定位产物文件"); + return; + } + try { + await revealSessionFileInFinder(resolvedSessionId, artifactPath); + } catch (error) { + console.warn("[ThemeWorkbenchSidebar] 定位产物文件失败:", error); + toast.error(formatActionErrorMessage("定位产物文件失败", error)); + } + }, + [runDetailSessionId], + ); + const handleOpenArtifactWithDefaultApp = useCallback( + async (artifactPath: string, sessionId?: string | null) => { + const resolvedSessionId = sessionId?.trim() || runDetailSessionId; + if (!resolvedSessionId) { + toast.error("缺少会话ID,无法打开产物文件"); + return; + } + try { + await openSessionFileWithDefaultApp(resolvedSessionId, artifactPath); + } catch (error) { + console.warn("[ThemeWorkbenchSidebar] 打开产物文件失败:", error); + toast.error(formatActionErrorMessage("打开产物文件失败", error)); + } + }, + [runDetailSessionId], + ); + const searchInputRef = useRef(null); + const isSearchActionDisabled = + contextSearchLoading || + Boolean(contextSearchBlockedReason) || + contextSearchQuery.trim().length === 0; + const activeContextItems = useMemo( + () => contextItems.filter((item) => item.active), + [contextItems], + ); + const searchContextItems = useMemo( + () => contextItems.filter((item) => item.source === "search"), + [contextItems], + ); + const orderedContextItems = useMemo( + () => + [...contextItems].sort((left, right) => { + if (left.active !== right.active) { + return left.active ? -1 : 1; + } + if (left.source !== right.source) { + return left.source === "search" ? -1 : 1; + } + const createdDelta = (right.createdAt || 0) - (left.createdAt || 0); + if (createdDelta !== 0) { + return createdDelta; + } + return left.name.localeCompare(right.name, "zh-CN"); + }), + [contextItems], + ); + + useEffect(() => { + const now = performance.now(); + const sinceLastCommitMs = + lastCommitAtRef.current === null ? null : now - lastCommitAtRef.current; + lastCommitAtRef.current = now; + logRenderPerf( + "ThemeWorkbenchSidebar", + currentRenderCount, + sinceLastCommitMs, + { + activeTab, + showActivityLogs, + contextSearchLoading, + branchItemsCount: branchItems.length, + workflowStepsCount: workflowSteps.length, + contextItemsCount: contextItems.length, + activeContextCount: activeContextItems.length, + activityLogsCount: activityLogs.length, + creationTaskEventsCount: creationTaskEvents.length, + hasActiveRunDetail: Boolean(activeRunDetail), + }, + ); + }); + const latestSearchLabel = useMemo(() => { + if (searchContextItems.length === 0) { + return "尚未联网检索"; + } + const latestLabel = formatContextCreatedAt(searchContextItems[0]?.createdAt); + return latestLabel ? `最近检索 ${latestLabel}` : `已生成 ${searchContextItems.length} 条结果`; + }, [searchContextItems]); + const selectedSearchResult = useMemo( + () => + searchContextItems.find((item) => item.id === selectedSearchResultId) || null, + [searchContextItems, selectedSearchResultId], + ); + const [addContextDialogOpen, setAddContextDialogOpen] = useState(false); + const [addTextDialogOpen, setAddTextDialogOpen] = useState(false); + const [addLinkDialogOpen, setAddLinkDialogOpen] = useState(false); + const [contextDraftText, setContextDraftText] = useState(""); + const [contextDraftLink, setContextDraftLink] = useState(""); + const [contextCreateLoading, setContextCreateLoading] = useState(false); + const [contextCreateError, setContextCreateError] = useState(null); + const [contextDropActive, setContextDropActive] = useState(false); + const closeAllContextDialogs = useCallback(() => { + setAddContextDialogOpen(false); + setAddTextDialogOpen(false); + setAddLinkDialogOpen(false); + setContextDropActive(false); + setContextCreateError(null); + setContextDraftText(""); + setContextDraftLink(""); + }, []); + const openAddContextDialog = useCallback(() => { + setContextCreateError(null); + setAddLinkDialogOpen(false); + setAddTextDialogOpen(false); + setAddContextDialogOpen(true); + }, []); + const runContextAction = useCallback( + async (action: () => Promise, successMessage: string) => { + setContextCreateLoading(true); + setContextCreateError(null); + try { + await action(); + toast.success(successMessage); + closeAllContextDialogs(); + } catch (error) { + const nextError = formatActionErrorMessage("添加上下文失败", error); + setContextCreateError(nextError); + } finally { + setContextCreateLoading(false); + } + }, + [closeAllContextDialogs], + ); + const handleChooseContextFile = useCallback(async () => { + if (!onAddFileContext) { + setContextCreateError("当前版本暂不支持上传文件上下文"); + return; + } + + try { + const selected = await openDialog({ + multiple: false, + directory: false, + }); + if (!selected || typeof selected !== "string") { + return; + } + + await runContextAction( + async () => { + await onAddFileContext({ + path: selected, + name: resolveFileNameFromPath(selected), + }); + }, + "已添加文件上下文", + ); + } catch (error) { + const nextError = formatActionErrorMessage("读取文件失败", error); + setContextCreateError(nextError); + } + }, [onAddFileContext, runContextAction]); + const handleDropContextFile = useCallback( + async (event: React.DragEvent) => { + event.preventDefault(); + setContextDropActive(false); + + const file = event.dataTransfer.files?.[0]; + if (!file) { + return; + } + + const fileWithPath = file as File & { path?: string }; + if (fileWithPath.path && onAddFileContext) { + await runContextAction( + async () => { + await onAddFileContext({ + path: fileWithPath.path || "", + name: file.name, + }); + }, + "已添加文件上下文", + ); + return; + } + + if (!onAddTextContext) { + setContextCreateError("当前环境无法读取拖拽文件路径,请使用“上传文件”按钮"); + return; + } + + await runContextAction( + async () => { + const content = await file.text(); + if (!content.trim()) { + throw new Error("文件内容为空"); + } + await onAddTextContext({ + content, + name: file.name, + }); + }, + "已添加文本上下文", + ); + }, + [onAddFileContext, onAddTextContext, runContextAction], + ); + const handleSubmitTextContext = useCallback(async () => { + if (!onAddTextContext) { + setContextCreateError("当前版本暂不支持输入文本上下文"); + return; + } + const normalizedText = contextDraftText.trim(); + if (!normalizedText) { + setContextCreateError("请输入文本内容"); + return; + } + await runContextAction( + async () => { + await onAddTextContext({ + content: normalizedText, + }); + }, + "已添加文本上下文", + ); + }, [contextDraftText, onAddTextContext, runContextAction]); + const handleSubmitLinkContext = useCallback(async () => { + if (!onAddLinkContext) { + setContextCreateError("当前版本暂不支持网站链接上下文"); + return; + } + const normalizedLink = contextDraftLink.trim(); + if (!normalizedLink) { + setContextCreateError("请输入网站链接"); + return; + } + await runContextAction( + async () => { + await onAddLinkContext({ + url: normalizedLink, + }); + }, + "已添加网站链接上下文", + ); + }, [contextDraftLink, onAddLinkContext, runContextAction]); + const groupedActivityLogs = useMemo(() => { + if (activityLogs.length === 0) { + return []; + } + + const groups: ActivityLogGroup[] = []; + const groupByKey = new Map(); + + activityLogs.forEach((log) => { + const identity = resolveActivityGroupKey(log); + const existingGroup = groupByKey.get(identity.key); + if (!existingGroup) { + const nextGroup: ActivityLogGroup = { + key: identity.key, + runId: identity.runId, + sessionId: log.sessionId?.trim() || undefined, + messageId: identity.messageId, + status: log.status, + source: log.source, + gateKey: log.gateKey, + timeLabel: log.timeLabel, + artifactPaths: normalizeArtifactPaths(log.artifactPaths), + logs: [log], + }; + groups.push(nextGroup); + groupByKey.set(identity.key, nextGroup); + return; + } + + existingGroup.logs.push(log); + existingGroup.status = mergeActivityStatus(existingGroup.status, log.status); + if (!existingGroup.source && log.source) { + existingGroup.source = log.source; + } + if (!existingGroup.sessionId && log.sessionId?.trim()) { + existingGroup.sessionId = log.sessionId.trim(); + } + if (!existingGroup.gateKey && log.gateKey) { + existingGroup.gateKey = log.gateKey; + } + if ( + (existingGroup.timeLabel === "--:--" || !existingGroup.timeLabel) && + log.timeLabel && + log.timeLabel !== "--:--" + ) { + existingGroup.timeLabel = log.timeLabel; + } + existingGroup.artifactPaths = mergeArtifactPaths( + existingGroup.artifactPaths, + log.artifactPaths, + ); + }); + + return groups; + }, [activityLogs]); + + const groupedCreationTaskEvents = useMemo(() => { + if (creationTaskEvents.length === 0) { + return []; + } + + const groupMap = new Map(); + creationTaskEvents.forEach((task) => { + const groupKey = task.taskType.trim().toLowerCase() || "unknown"; + const existing = groupMap.get(groupKey); + if (!existing) { + groupMap.set(groupKey, { + key: groupKey, + taskType: task.taskType, + label: formatCreationTaskTypeLabel(task.taskType), + latestTimeLabel: task.timeLabel, + tasks: [task], + }); + return; + } + existing.tasks.push(task); + if ( + task.createdAt > + (existing.tasks[0]?.createdAt || Number.MIN_SAFE_INTEGER) + ) { + existing.latestTimeLabel = task.timeLabel; + } + }); + + return Array.from(groupMap.values()) + .map((group) => { + const sortedTasks = [...group.tasks].sort( + (left, right) => right.createdAt - left.createdAt, + ); + return { + ...group, + latestTimeLabel: sortedTasks[0]?.timeLabel || group.latestTimeLabel, + tasks: sortedTasks, + }; + }) + .sort((left, right) => { + const leftLatest = left.tasks[0]?.createdAt || 0; + const rightLatest = right.tasks[0]?.createdAt || 0; + return rightLatest - leftLatest; + }); + }, [creationTaskEvents]); + + const resolveActivityGroupSessionId = useCallback( + (group: ActivityLogGroup): string | null => { + const normalizedGroupSessionId = group.sessionId?.trim(); + if (normalizedGroupSessionId) { + return normalizedGroupSessionId; + } + if (group.runId && activeRunDetail?.id === group.runId && runDetailSessionId) { + return runDetailSessionId; + } + return null; + }, + [activeRunDetail?.id, runDetailSessionId], + ); + + const renderCompactContextList = ( + items: ThemeWorkbenchSidebarProps["contextItems"], + emptyText: string, + ) => { + if (items.length === 0) { + return {emptyText}; + } + + return ( + + {items.map((item) => { + const interactive = item.source === "search"; + const createdAtLabel = formatContextCreatedAt(item.createdAt); + return ( + + { + if (interactive) { + setSelectedSearchResultId(item.id); + } else if (onViewContextDetail) { + onViewContextDetail(item.id); + } + }} + > + {item.source === "search" ? ( + + {item.searchMode === "social" ? : } + + ) : ( + + + + )} + + {item.name} + + {resolveContextSourceSubLabel(item.source, item.searchMode)} + {createdAtLabel ? ` · ${createdAtLabel}` : ""} + + + + + onToggleContextActive(item.id)} + /> + + ); + })} + + ); + }; + + + + return ( + + + Theme Workbench + + {activeTab === "context" ? "上下文管理" : isVersionMode ? "编排与版本" : "编排与分支"} + + + {activeTab === "context" + ? "检索、筛选并启用当前创作真正会用到的上下文。" + : "跟踪编排进度、产物版本与运行记录。"} + + + setActiveTab("context")} + > + 上下文管理 + + {activeContextItems.length} + + + setActiveTab("workflow")} + > + 编排工作台 + + {branchItems.length} + + + + + {onRequestCollapse ? ( + + + + ) : null} + + + {activeTab === "context" ? ( + <> +
+ + 搜索上下文 + {latestSearchLabel} + + + + 添加上下文 + + + + + onContextSearchQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !isSearchActionDisabled) { + event.preventDefault(); + void onSubmitContextSearch(); + } + }} + /> + + + + + + {contextSearchMode === "social" ? ( + + ) : ( + + )} + {contextSearchMode === "social" ? "社交媒体" : "网络搜索"} + + + + + onContextSearchModeChange("web")}> + + + 网络搜索 + {contextSearchMode === "web" ? ( + + + + ) : null} + + + onContextSearchModeChange("social")}> + + + 社交媒体 + {contextSearchMode === "social" ? ( + + + + ) : null} + + + + + { + if (!isSearchActionDisabled) { + void onSubmitContextSearch(); + } + }} + $disabled={isSearchActionDisabled} + disabled={isSearchActionDisabled} + > + {contextSearchLoading ? ( + + ) : ( + + )} + + + + {contextSearchError ? ( + {contextSearchError} + ) : contextSearchLoading ? ( + 正在联网检索并整理上下文... + ) : contextSearchBlockedReason ? ( + {contextSearchBlockedReason} + ) : ( + 输入关键词后按 Enter,可直接把检索结果加入当前上下文。 + )} +
+ + {selectedSearchResult ? ( +
+ + + 搜索结果详情 + + setSelectedSearchResultId(null)} + > + + 返回列表 + + + + {selectedSearchResult.name} + + {resolveContextSourceSubLabel( + selectedSearchResult.source, + selectedSearchResult.searchMode, + )} + {formatContextCreatedAt(selectedSearchResult.createdAt) + ? ` · ${formatContextCreatedAt(selectedSearchResult.createdAt)}` + : ''} + {selectedSearchResult.active ? ' · 已启用' : ' · 未启用'} + + + Source guide + {selectedSearchResult.query ? ( + 检索词:{selectedSearchResult.query} + ) : null} + {selectedSearchResult.citations && selectedSearchResult.citations.length > 0 ? ( + + {selectedSearchResult.citations.map((citation) => ( + + + {citation.title} + + ))} + + ) : ( + 暂无来源链接 + )} + + + {selectedSearchResult.previewText || '暂无可展示的搜索结果正文'} + + + onToggleContextActive(selectedSearchResult.id)}> + {selectedSearchResult.active ? '移出上下文' : '加入上下文'} + + + +
+ ) : ( +
+ + 上下文列表 + {contextItems.length} 条 + + + 已生效 {contextBudget.activeCount}/{contextBudget.activeCountLimit} 条 · + 检索结果 {searchContextItems.length} 条 · 估算 {contextBudget.estimatedTokens}/ + {contextBudget.tokenLimit} tokens + + 搜索结果可点击查看详情,其他上下文可直接勾选启用。 + {renderCompactContextList( + orderedContextItems, + '当前还没有上下文,先添加项目资料或搜索一个主题试试', + )} +
+ )} + + ) : ( + <> +
+ + + + + {isVersionMode ? '创建版本快照' : '新建分支话题'} + + + + + + + {isVersionMode ? '创建版本快照' : '新建分支话题'} + + {onAddImage && ( + + + 添加图片 + + )} + {onImportDocument && ( + + + 导入文稿 + + )} + + +
+ +
+ + 编排进度 + {workflowSteps.length - completedSteps} + + + {completedSteps}/{workflowSteps.length} 步已完成 + + + + + + {workflowSteps.map((step) => ( + + {getStepIcon(step.status)} + {step.title} + + ))} + +
+ +
+ + {isVersionMode ? '产物版本' : '篇内分支'} + {branchItems.length} + + + {branchItems.length === 0 ? ( + + {isVersionMode ? '暂无文稿版本,先生成或创建快照' : '暂无分支话题'} + + ) : ( + branchItems.map((item) => ( + + + + onSwitchTopic(item.id)}> + {item.title} + + + {getBranchStatusText(item.status)} + + {!isVersionMode ? ( + onDeleteTopic(item.id)} aria-label="删除分支"> + + + ) : null} + + + onSetBranchStatus(item.id, 'merged')}> + {isVersionMode ? '设为主稿' : '采纳到主稿'} + + onSetBranchStatus(item.id, 'pending')}> + {isVersionMode ? '标记待评审' : '标记待决策'} + + + + )) + )} + +
+ +
+ + 任务提交 + + {creationTaskEvents.length} + + + + {showCreationTasks ? ( + + {groupedCreationTaskEvents.length === 0 ? ( + 暂无任务提交 + ) : ( + groupedCreationTaskEvents.map((group) => ( + + + ● + {group.label} + + {group.latestTimeLabel} + + + + 类型:{group.taskType} · 本组 {group.tasks.length} 条 + + + {group.tasks.map((task) => ( + + + • + {task.path} + + {task.timeLabel} + + + 任务ID:{task.taskId} + {task.absolutePath ? ( + + + + {task.absolutePath} + + { + void writeClipboardText(task.absolutePath || ""); + }} + > + 复制绝对路径 + + + + ) : ( + + { + void writeClipboardText(task.path); + }} + > + 复制路径 + + + )} + + ))} + + + )) + )} + + ) : null} +
+ +
+ + 活动日志 + + + {showActivityLogs ? ( + <> + + {groupedActivityLogs.length === 0 ? ( + 暂无日志 + ) : ( + groupedActivityLogs.map((group) => { + const artifactSessionId = resolveActivityGroupSessionId(group); + return ( + item.contextIds || [])] + .filter((value): value is string => Boolean(value && value.trim())) + .join(', ')} + > + + {resolveActivityMarker(group.status)} + + {group.runId + ? '编排运行' + : group.messageId + ? '会话工具流' + : '未关联运行'} + + {group.timeLabel} + + + {formatGateLabel(group.gateKey) + ? `闸门:${formatGateLabel(group.gateKey)}` + : '闸门:未标注'} + {group.source ? ` · 来源:${group.source}` : ''} + {formatRunIdShort(group.runId) + ? ' · ' + : formatRunIdShort(group.messageId) + ? ` · 会话:${formatRunIdShort(group.messageId)}` + : ''} + {formatRunIdShort(group.runId) ? ( + { + if (group.runId) { + onViewRunDetail?.(group.runId); + } + }} + > + 运行:{formatRunIdShort(group.runId)} + + ) : null} + + {group.artifactPaths.length > 0 ? ( + + {group.artifactPaths.map((artifactPath) => ( + + {artifactPath} + { + void writeClipboardText(artifactPath); + }} + > + 复制 + + { + void handleRevealArtifactInFinder( + artifactPath, + artifactSessionId, + ); + }} + > + 定位 + + { + void handleOpenArtifactWithDefaultApp( + artifactPath, + artifactSessionId, + ); + }} + > + 打开 + + + ))} + + ) : null} + + {group.logs.map((log) => ( + + + {resolveActivityMarker(log.status)} + {log.name} + + {log.durationLabel || log.timeLabel} + + + + {log.applyTarget ? `目标:${log.applyTarget}` : '目标:主稿内容'} · + 上下文:{log.contextIds?.length || 0} 条 + {formatGateLabel(log.gateKey) + ? ` · 闸门:${formatGateLabel(log.gateKey)}` + : ''} + {log.source ? ` · 来源:${log.source}` : ''} + {log.durationLabel ? ` · 耗时:${log.durationLabel}` : ''} + + {formatLogActionLabel(log)} + + 修改:{formatArtifactPathsLabel(log.artifactPaths)} + + {log.inputSummary ? ( + 输入:{log.inputSummary} + ) : null} + {log.outputSummary ? ( + 输出:{log.outputSummary} + ) : null} + + ))} + + + ); + }) + )} + + {activeRunDetailLoading ? ( + + 运行详情 + 加载中... + + ) : activeRunDetail ? ( + + 运行详情 + ID:{activeRunDetail.id} + + 状态:{formatRunStatusLabel(activeRunDetail.status)} + + 来源:{activeRunDetail.source} + + 会话:{activeRunDetail.session_id || '-'} + + + 开始:{activeRunDetail.started_at} + + + 结束:{activeRunDetail.finished_at || '-'} + + + 耗时:{activeRunDetail.duration_ms ?? '-'}ms + + + { + void writeClipboardText(activeRunDetail.id); + }} + > + 复制运行ID + + { + void writeClipboardText(runMetadataText); + }} + > + 复制元数据 + + + {runMetadataSummary.workflow ? ( + 工作流:{runMetadataSummary.workflow} + ) : null} + {runMetadataSummary.executionId ? ( + 执行ID:{runMetadataSummary.executionId} + ) : null} + {runMetadataSummary.versionId ? ( + 版本ID:{runMetadataSummary.versionId} + ) : null} + {runMetadataSummary.stages.length > 0 ? ( + + 阶段: + {runMetadataSummary.stages + .map((stage) => formatStageLabelByKey(stage)) + .join(' → ')} + + ) : null} + {runMetadataSummary.artifactPaths.length > 0 ? ( + + {runMetadataSummary.artifactPaths.map((artifactPath) => ( + + {artifactPath} + { + void writeClipboardText(artifactPath); + }} + > + 复制路径 + + { + void handleRevealArtifactInFinder(artifactPath); + }} + > + 在 Finder 中定位 + + { + void handleOpenArtifactWithDefaultApp(artifactPath); + }} + > + 打开 + + + ))} + + ) : null} + {runMetadataText} + + ) : null} + + ) : null} +
+ + )} +
+ {addContextDialogOpen ? ( + { + if (!contextCreateLoading) { + closeAllContextDialogs(); + } + }} + > + event.stopPropagation()}> + + 添加新上下文 + + { + if (!contextCreateLoading) { + closeAllContextDialogs(); + } + }} + > + + + + + + { + event.preventDefault(); + setContextDropActive(true); + }} + onDragLeave={() => setContextDropActive(false)} + onDrop={(event) => { + void handleDropContextFile(event); + }} + > + or drop your files here + + { + if (!contextCreateLoading) { + void handleChooseContextFile(); + } + }} + > + + 上传文件 + + { + if (contextCreateLoading) { + return; + } + setContextCreateError(null); + setAddContextDialogOpen(false); + setAddTextDialogOpen(false); + setAddLinkDialogOpen(true); + }} + > + + 网站链接 + + { + if (contextCreateLoading) { + return; + } + setContextCreateError(null); + setAddContextDialogOpen(false); + setAddLinkDialogOpen(false); + setAddTextDialogOpen(true); + }} + > + + 输入文本 + + + + {contextCreateError ? ( + {contextCreateError} + ) : null} + + + + ) : null} + {addTextDialogOpen ? ( + { + if (!contextCreateLoading) { + closeAllContextDialogs(); + } + }} + > + event.stopPropagation()}> + + + { + if (contextCreateLoading) { + return; + } + setContextCreateError(null); + setAddTextDialogOpen(false); + setAddContextDialogOpen(true); + }} + > + + + + 添加文本内容 + + { + if (!contextCreateLoading) { + closeAllContextDialogs(); + } + }} + > + + + + + + { + setContextCreateError(null); + setContextDraftText(event.target.value); + }} + /> + {contextCreateError ? ( + {contextCreateError} + ) : null} + + { + void handleSubmitTextContext(); + }} + > + {contextCreateLoading ? : "确认"} + + + + + + ) : null} + {addLinkDialogOpen ? ( + { + if (!contextCreateLoading) { + closeAllContextDialogs(); + } + }} + > + event.stopPropagation()}> + + + { + if (contextCreateLoading) { + return; + } + setContextCreateError(null); + setAddLinkDialogOpen(false); + setAddContextDialogOpen(true); + }} + > + + + + 添加网站链接 + + { + if (!contextCreateLoading) { + closeAllContextDialogs(); + } + }} + > + + + + + + { + setContextCreateError(null); + setContextDraftLink(event.target.value); + }} + /> + {contextCreateError ? ( + {contextCreateError} + ) : null} + + { + void handleSubmitLinkContext(); + }} + > + {contextCreateLoading ? : "确认"} + + + + + + ) : null} +
+ ); +} + +function areThemeWorkbenchSidebarPropsEqual( + previous: ThemeWorkbenchSidebarProps, + next: ThemeWorkbenchSidebarProps, +): boolean { + return ( + previous.branchMode === next.branchMode && + previous.onNewTopic === next.onNewTopic && + previous.onSwitchTopic === next.onSwitchTopic && + previous.onDeleteTopic === next.onDeleteTopic && + previous.branchItems === next.branchItems && + previous.onSetBranchStatus === next.onSetBranchStatus && + previous.workflowSteps === next.workflowSteps && + previous.contextSearchQuery === next.contextSearchQuery && + previous.onContextSearchQueryChange === next.onContextSearchQueryChange && + previous.contextSearchMode === next.contextSearchMode && + previous.onContextSearchModeChange === next.onContextSearchModeChange && + previous.contextSearchLoading === next.contextSearchLoading && + previous.contextSearchError === next.contextSearchError && + previous.contextSearchBlockedReason === next.contextSearchBlockedReason && + previous.onSubmitContextSearch === next.onSubmitContextSearch && + previous.onAddTextContext === next.onAddTextContext && + previous.onAddLinkContext === next.onAddLinkContext && + previous.onAddFileContext === next.onAddFileContext && + previous.onAddImage === next.onAddImage && + previous.onImportDocument === next.onImportDocument && + previous.contextItems === next.contextItems && + previous.onToggleContextActive === next.onToggleContextActive && + previous.contextBudget.activeCount === next.contextBudget.activeCount && + previous.contextBudget.activeCountLimit === next.contextBudget.activeCountLimit && + previous.contextBudget.estimatedTokens === next.contextBudget.estimatedTokens && + previous.contextBudget.tokenLimit === next.contextBudget.tokenLimit && + previous.activityLogs === next.activityLogs && + previous.creationTaskEvents === next.creationTaskEvents && + previous.onViewRunDetail === next.onViewRunDetail && + previous.activeRunDetail === next.activeRunDetail && + previous.activeRunDetailLoading === next.activeRunDetailLoading && + previous.onRequestCollapse === next.onRequestCollapse + ); +} + +export const ThemeWorkbenchSidebar = memo( + ThemeWorkbenchSidebarComponent, + areThemeWorkbenchSidebarPropsEqual, +); diff --git a/src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.test.tsx b/src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.test.tsx new file mode 100644 index 000000000..ec69707da --- /dev/null +++ b/src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.test.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeWorkbenchSkillsPanel } from "./ThemeWorkbenchSkillsPanel"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderPanel( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: React.ComponentProps = { + skills: [ + { + key: "social_post_with_cover", + name: "social_post_with_cover", + description: "社媒文案与封面生成", + directory: "social_post_with_cover", + installed: true, + }, + { + key: "research", + name: "research", + description: "信息检索与趋势分析", + directory: "research", + installed: true, + }, + { + key: "typesetting", + name: "typesetting", + description: "主稿排版与润色", + directory: "typesetting", + installed: true, + }, + ], + currentGate: { + key: "topic_select", + title: "选题闸门", + status: "waiting", + description: "请确认本轮选题方向", + }, + workspaceSummary: { + activeContextCount: 2, + searchResultCount: 5, + versionCount: 3, + runState: "await_user_decision", + }, + onTriggerSkill: vi.fn(), + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { + container, + props: { ...defaultProps, ...props }, + }; +} + +describe("ThemeWorkbenchSkillsPanel", () => { + it("传入折叠回调时应显示折叠按钮并可触发", () => { + const onRequestCollapse = vi.fn(); + const { container } = renderPanel({ onRequestCollapse }); + + const collapseButton = container.querySelector( + 'button[aria-label="折叠操作面板"]', + ) as HTMLButtonElement | null; + expect(collapseButton).toBeTruthy(); + + if (collapseButton) { + act(() => { + collapseButton.click(); + }); + } + expect(onRequestCollapse).toHaveBeenCalledTimes(1); + }); + + it("应显示操作面板、阶段摘要、推荐动作与统计信息", () => { + const { container } = renderPanel(); + 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("research"); + expect(container.textContent).toContain("social_post_with_cover"); + }); + + it("点击推荐技能应触发 onTriggerSkill 回调", () => { + const onTriggerSkill = vi.fn(); + const { container } = renderPanel({ onTriggerSkill }); + + const skillButton = container.querySelector( + 'button[aria-label="执行技能 research"]', + ) as HTMLButtonElement | null; + expect(skillButton).not.toBeNull(); + + if (skillButton) { + act(() => { + skillButton.click(); + }); + } + + expect(onTriggerSkill).toHaveBeenCalledTimes(1); + expect(onTriggerSkill.mock.calls[0][0]?.key).toBe("research"); + }); +}); diff --git a/src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.tsx b/src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.tsx new file mode 100644 index 000000000..8a41f8fc8 --- /dev/null +++ b/src/components/agent/chat/components/ThemeWorkbenchSkillsPanel.tsx @@ -0,0 +1,656 @@ +import { useMemo } from "react"; +import styled from "styled-components"; +import { + ChevronRight, + FileText, + Image, + PanelRightClose, + Search, + Sparkles, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { Skill } from "@/lib/api/skills"; + +type SkillGroupKey = "text" | "visual" | "audio" | "video" | "resource"; +type ThemeWorkbenchRunState = "idle" | "auto_running" | "await_user_decision"; + +interface SkillGroup { + key: SkillGroupKey; + title: string; + items: Skill[]; +} + +interface CurrentGate { + key: string; + title: string; + status: "running" | "waiting" | "idle" | "done"; + description: string; +} + +interface ThemeWorkbenchWorkspaceSummary { + activeContextCount: number; + searchResultCount: number; + versionCount: number; + runState: ThemeWorkbenchRunState; +} + +const PanelContainer = styled.aside` + width: 320px; + min-width: 320px; + height: 100%; + border-left: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.14); + display: flex; + flex-direction: column; + overflow: hidden; +`; + +const PanelHeader = styled.div` + padding: 14px 14px 12px; + border-bottom: 1px solid hsl(var(--border) / 0.75); + background: hsl(var(--background) / 0.92); + backdrop-filter: blur(12px); +`; + +const PanelHeaderTop = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +`; + +const PanelCollapseButton = styled.button` + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + + &:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--primary) / 0.35); + background: hsl(var(--accent) / 0.45); + } +`; + +const PanelEyebrow = styled.div` + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: hsl(var(--muted-foreground)); +`; + +const PanelTitle = styled.div` + margin-top: 4px; + font-size: 16px; + font-weight: 700; + color: hsl(var(--foreground)); +`; + +const PanelDescription = styled.div` + margin-top: 4px; + font-size: 12px; + line-height: 1.45; + color: hsl(var(--muted-foreground)); +`; + +const Section = styled.section` + padding: 12px 14px; + border-bottom: 1px solid hsl(var(--border) / 0.72); +`; + +const ScrollSection = styled(Section)` + flex: 1; + min-height: 0; + overflow-y: auto; +`; + +const SectionTitle = styled.div` + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: hsl(var(--muted-foreground)); + margin-bottom: 8px; +`; + +const GateCard = styled.div` + border: 1px solid hsl(var(--border)); + border-radius: 14px; + background: linear-gradient( + 180deg, + hsl(var(--background)) 0%, + hsl(var(--muted) / 0.36) 100% + ); + padding: 12px; +`; + +const GateHead = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const GateTitle = styled.div` + font-size: 14px; + font-weight: 700; + color: hsl(var(--foreground)); +`; + +const GateStatus = styled.span<{ $status: "running" | "waiting" | "idle" | "done" }>` + margin-left: auto; + padding: 3px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + background: ${(props) => + props.$status === "waiting" + ? "hsl(38 96% 90%)" + : props.$status === "running" + ? "hsl(var(--primary) / 0.16)" + : props.$status === "idle" + ? "hsl(var(--muted) / 0.7)" + : "hsl(142 76% 90%)"}; + color: ${(props) => + props.$status === "waiting" + ? "hsl(30 90% 35%)" + : props.$status === "running" + ? "hsl(var(--primary))" + : props.$status === "idle" + ? "hsl(var(--muted-foreground))" + : "hsl(142 76% 30%)"}; +`; + +const GateDesc = styled.div` + margin-top: 8px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.45; +`; + +const MetricGrid = styled.div` + margin-top: 10px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +`; + +const MetricCard = styled.div` + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--background)); + padding: 10px; +`; + +const MetricValue = styled.div` + font-size: 16px; + font-weight: 700; + color: hsl(var(--foreground)); + line-height: 1.2; + word-break: break-word; +`; + +const MetricLabel = styled.div` + margin-top: 4px; + font-size: 11px; + color: hsl(var(--muted-foreground)); +`; + +const HintText = styled.div` + margin-top: 8px; + font-size: 11px; + line-height: 1.45; + color: hsl(var(--muted-foreground)); +`; + +const ActionList = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ActionCard = styled.div<{ $featured?: boolean }>` + border: 1px solid + ${(props) => + props.$featured ? "hsl(var(--primary) / 0.35)" : "hsl(var(--border))"}; + border-radius: 14px; + background: ${(props) => + props.$featured ? "hsl(var(--primary) / 0.06)" : "hsl(var(--background))"}; + padding: 12px; +`; + +const ActionHead = styled.div` + display: flex; + align-items: flex-start; + gap: 10px; +`; + +const ActionIconWrap = styled.div<{ $featured?: boolean }>` + width: 30px; + height: 30px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${(props) => + props.$featured ? "hsl(var(--primary) / 0.14)" : "hsl(var(--muted) / 0.8)"}; + color: ${(props) => + props.$featured ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + flex-shrink: 0; +`; + +const ActionMeta = styled.div` + flex: 1; + min-width: 0; +`; + +const ActionName = styled.div` + font-size: 13px; + font-weight: 700; + color: hsl(var(--foreground)); + line-height: 1.35; +`; + +const ActionDescription = styled.div` + margin-top: 4px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.45; +`; + +const ActionTag = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 8px; + padding: 0 8px; + height: 22px; + border-radius: 999px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-size: 10px; + font-weight: 600; +`; + +const ActionButton = styled.button` + margin-top: 10px; + width: 100%; + height: 34px; + border-radius: 10px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 12px; + font-weight: 600; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: hsl(var(--primary) / 0.4); + background: hsl(var(--primary) / 0.08); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const GroupTitle = styled.div` + margin: 14px 0 8px; + font-size: 12px; + font-weight: 700; + color: hsl(var(--foreground)); +`; + +function resolveSkillGroup(skill: Skill): SkillGroupKey { + const feature = `${skill.key} ${skill.name} ${skill.description}`.toLowerCase(); + if ( + feature.includes("cover") || + feature.includes("image") || + feature.includes("illustration") || + feature.includes("poster") + ) { + return "visual"; + } + if ( + feature.includes("broadcast") || + feature.includes("audio") || + feature.includes("podcast") || + feature.includes("music") + ) { + return "audio"; + } + if (feature.includes("video")) { + return "video"; + } + if ( + feature.includes("resource") || + feature.includes("research") || + feature.includes("library") || + feature.includes("url") || + feature.includes("search") + ) { + return "resource"; + } + return "text"; +} + +function getGroupTitle(groupKey: SkillGroupKey): string { + if (groupKey === "text") return "文字能力"; + if (groupKey === "visual") return "视觉能力"; + if (groupKey === "audio") return "音频能力"; + if (groupKey === "video") return "视频能力"; + return "检索与资源"; +} + +function resolveGateStatusText(status: CurrentGate["status"]): string { + if (status === "waiting") return "等待决策"; + if (status === "running") return "自动执行"; + if (status === "idle") return "待启动"; + return "已完成"; +} + +function resolveRunStateText(runState: ThemeWorkbenchRunState): string { + if (runState === "auto_running") return "执行中"; + if (runState === "await_user_decision") return "待决策"; + return "空闲"; +} + +function resolveSkillIcon(skill: Skill): LucideIcon { + const group = resolveSkillGroup(skill); + if (group === "resource") { + return Search; + } + if (group === "visual") { + return Image; + } + if (group === "text") { + return FileText; + } + return Sparkles; +} + +function resolveSkillActionLabel(skill: Skill): string { + const group = resolveSkillGroup(skill); + if (group === "resource") { + return "开始检索"; + } + if (group === "visual") { + return "生成素材"; + } + return "立即执行"; +} + +function buildSkillFeatureProbe(skill: Skill): string { + return `${skill.key} ${skill.name} ${skill.description || ""}`.toLowerCase(); +} + +function pickRecommendedSkills(skills: Skill[], gateKey: string): Skill[] { + const tagsByGate: Record = { + topic_select: ["research", "social_post_with_cover"], + write_mode: ["social_post_with_cover", "typesetting", "cover"], + publish_confirm: ["typesetting", "cover", "social_post_with_cover"], + }; + + const preferredTags = tagsByGate[gateKey] || ["social_post_with_cover", "research"]; + const selected: Skill[] = []; + + preferredTags.forEach((tag) => { + const found = skills.find((skill) => { + if (selected.some((item) => item.key === skill.key)) { + return false; + } + return buildSkillFeatureProbe(skill).includes(tag); + }); + if (found) { + selected.push(found); + } + }); + + if (selected.length < 2) { + skills.forEach((skill) => { + if (selected.length >= 2) { + return; + } + if (!selected.some((item) => item.key === skill.key)) { + selected.push(skill); + } + }); + } + + return selected.slice(0, 2); +} + +interface ThemeWorkbenchSkillsPanelProps { + skills: Skill[]; + currentGate: CurrentGate; + disabled?: boolean; + workspaceSummary?: ThemeWorkbenchWorkspaceSummary; + onTriggerSkill?: (skill: Skill) => void; + onRequestCollapse?: () => void; +} + +export function ThemeWorkbenchSkillsPanel({ + skills, + currentGate, + disabled = false, + workspaceSummary, + onTriggerSkill, + onRequestCollapse, +}: ThemeWorkbenchSkillsPanelProps) { + const fallbackSkills: Skill[] = useMemo( + () => [ + { + key: "social_post_with_cover", + name: "social_post_with_cover", + description: "社媒主稿与封面图生成", + directory: "social_post_with_cover", + installed: true, + }, + { + key: "cover_generate", + name: "cover_generate", + description: "封面图生成", + directory: "cover_generate", + installed: true, + }, + { + key: "research", + name: "research", + description: "信息检索与趋势分析", + directory: "research", + installed: true, + }, + { + key: "typesetting", + name: "typesetting", + description: "主稿排版与润色", + directory: "typesetting", + installed: true, + }, + ], + [], + ); + + const availableSkills = useMemo( + () => { + const installed = skills.filter((skill) => skill.installed); + return installed.length > 0 ? installed : fallbackSkills; + }, + [fallbackSkills, skills], + ); + + const recommendedSkills = useMemo( + () => pickRecommendedSkills(availableSkills, currentGate.key), + [availableSkills, currentGate.key], + ); + + const groupedSkills = useMemo(() => { + const recommendedSkillKeys = new Set(recommendedSkills.map((skill) => skill.key)); + const buckets: Record = { + text: [], + visual: [], + audio: [], + video: [], + resource: [], + }; + + availableSkills.forEach((skill) => { + if (recommendedSkillKeys.has(skill.key)) { + return; + } + buckets[resolveSkillGroup(skill)].push(skill); + }); + + return (Object.keys(buckets) as SkillGroupKey[]) + .map((key) => ({ + key, + title: getGroupTitle(key), + items: buckets[key], + })) + .filter((group) => group.items.length > 0); + }, [availableSkills, recommendedSkills]); + + return ( + + + +
+ Theme Workbench + 操作面板 +
+ {onRequestCollapse ? ( + + + + ) : null} +
+ + 右侧聚焦当前阶段推荐动作,中间主稿区保持结果优先,减少来回跳转。 + +
+ +
+ 阶段摘要 + + + + {currentGate.title} + + {resolveGateStatusText(currentGate.status)} + + + {currentGate.description} + {workspaceSummary ? ( + + + {workspaceSummary.activeContextCount} + 启用上下文 + + + {workspaceSummary.searchResultCount} + 搜索结果 + + + {workspaceSummary.versionCount} + 版本快照 + + + {resolveRunStateText(workspaceSummary.runState)} + 运行状态 + + + ) : null} + + {disabled + ? "当前有任务执行中,建议等待本轮完成后再触发新的技能。" + : "先看推荐动作,再按需要选择更多能力,避免重复操作。"} + + +
+ + + 推荐动作 + + {recommendedSkills.map((skill) => { + const Icon = resolveSkillIcon(skill); + return ( + + + + + + + {skill.name} + + {skill.description || "使用当前能力继续推进本轮工作台任务。"} + + 推荐优先执行 + + + onTriggerSkill?.(skill)} + > + {resolveSkillActionLabel(skill)} + + + ); + })} + + + 可执行能力 + {groupedSkills.length === 0 ? ( + 当前可用技能已全部展示在推荐动作中,可直接开始执行。 + ) : ( + groupedSkills.map((group) => ( +
+ {group.title} + + {group.items.map((skill) => { + const Icon = resolveSkillIcon(skill); + return ( + + + + + + + {skill.name} + + {skill.description || "使用当前能力继续处理工作台内容。"} + + + + onTriggerSkill?.(skill)} + > + {resolveSkillActionLabel(skill)} + + + ); + })} + +
+ )) + )} +
+
+ ); +} diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts index ef7c940fa..eda412ae3 100644 --- a/src/components/agent/chat/hooks/index.ts +++ b/src/components/agent/chat/hooks/index.ts @@ -27,3 +27,5 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) { // 重新导出原有 hooks,便于直接使用 export { useAgentChat } from "./useAgentChat"; export { useAsterAgentChat } from "./useAsterAgentChat"; +export { useThemeContextWorkspace } from "./useThemeContextWorkspace"; +export { useTopicBranchBoard } from "./useTopicBranchBoard"; diff --git a/src/components/agent/chat/hooks/skillCommand.test.ts b/src/components/agent/chat/hooks/skillCommand.test.ts new file mode 100644 index 000000000..d2111cdd6 --- /dev/null +++ b/src/components/agent/chat/hooks/skillCommand.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Dispatch, SetStateAction } from "react"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import type { Message } from "../types"; +import { tryExecuteSlashSkillCommand } from "./skillCommand"; + +const { mockSafeListen, mockParseStreamEvent, mockListExecutableSkills, mockExecuteSkill } = + vi.hoisted(() => ({ + mockSafeListen: vi.fn(), + mockParseStreamEvent: vi.fn((payload: unknown) => payload), + mockListExecutableSkills: vi.fn(), + mockExecuteSkill: vi.fn(), + })); + +vi.mock("@/lib/dev-bridge", () => ({ + safeListen: mockSafeListen, +})); + +vi.mock("@/lib/api/agent", () => ({ + parseStreamEvent: mockParseStreamEvent, +})); + +vi.mock("@/lib/api/skill-execution", () => ({ + skillExecutionApi: { + listExecutableSkills: mockListExecutableSkills, + executeSkill: mockExecuteSkill, + }, +})); + +interface MessageStore { + getMessages: () => Message[]; + setMessages: Dispatch>; +} + +function createMessageStore(initial: Message[]): MessageStore { + let messages = [...initial]; + return { + getMessages: () => messages, + setMessages: (value) => { + messages = typeof value === "function" ? value(messages) : value; + }, + }; +} + +function buildBaseMessage(): Message { + return { + id: "assistant-1", + role: "assistant", + content: "", + timestamp: new Date(), + contentParts: [], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockListExecutableSkills.mockResolvedValue([ + { + name: "social_post_with_cover", + display_name: "social_post_with_cover", + description: "social", + execution_mode: "prompt", + has_workflow: false, + }, + ]); + mockSafeListen.mockResolvedValue((() => {}) as UnlistenFn); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("tryExecuteSlashSkillCommand 社媒主链路", () => { + it("当后端连续发出 write_file 工具事件时应写入主稿与辅助产物", async () => { + const store = createMessageStore([buildBaseMessage()]); + const onWriteFile = vi.fn(); + let streamHandler: ((event: { payload: unknown }) => void) | null = null; + + mockSafeListen.mockImplementation(async (_eventName, handler) => { + streamHandler = handler as (event: { payload: unknown }) => void; + return () => { + streamHandler = null; + }; + }); + + mockExecuteSkill.mockImplementation(async () => { + const emitWriteToolStart = (toolId: string, path: string, content: string) => { + streamHandler?.({ + payload: { + type: "tool_start", + tool_id: toolId, + tool_name: "write_file", + arguments: JSON.stringify({ + path, + content, + }), + }, + }); + }; + + emitWriteToolStart( + "tool-main", + "social-posts/demo.md", + "# 标题\n\n主稿正文", + ); + emitWriteToolStart( + "tool-cover", + "social-posts/demo.cover.json", + "{\"cover_url\":\"https://example.com/cover.png\",\"status\":\"成功\"}", + ); + emitWriteToolStart( + "tool-pack", + "social-posts/demo.publish-pack.json", + "{\"article_path\":\"social-posts/demo.md\",\"cover_meta_path\":\"social-posts/demo.cover.json\"}", + ); + streamHandler?.({ payload: { type: "final_done" } }); + + return { + success: true, + output: + '\n# 标题\n\n主稿正文\n', + steps_completed: [], + }; + }); + + const handled = await tryExecuteSlashSkillCommand({ + command: { + skillName: "social_post_with_cover", + userInput: "输出社媒文案", + }, + rawContent: "/social_post_with_cover 输出社媒文案", + assistantMsgId: "assistant-1", + providerType: "anthropic", + model: "claude-sonnet-4-20250514", + ensureSession: async () => "session-1", + setMessages: store.setMessages, + setIsSending: vi.fn(), + setCurrentAssistantMsgId: vi.fn(), + setStreamUnlisten: vi.fn(), + setActiveSessionIdForStop: vi.fn(), + isExecutionCancelled: () => false, + playTypewriterSound: vi.fn(), + playToolcallSound: vi.fn(), + onWriteFile, + }); + + expect(handled).toBe(true); + expect(onWriteFile).toHaveBeenCalledTimes(3); + + const writtenPaths = onWriteFile.mock.calls.map((call) => call[1]); + expect(writtenPaths).toContain("social-posts/demo.md"); + expect(writtenPaths).toContain("social-posts/demo.cover.json"); + expect(writtenPaths).toContain("social-posts/demo.publish-pack.json"); + }); + + it("当 executeSkill.output 包含 write_file 时应覆盖流式旧内容", async () => { + const store = createMessageStore([buildBaseMessage()]); + const onWriteFile = vi.fn(); + let streamHandler: ((event: { payload: unknown }) => void) | null = null; + + mockSafeListen.mockImplementation(async (_eventName, handler) => { + streamHandler = handler as (event: { payload: unknown }) => void; + return () => { + streamHandler = null; + }; + }); + + const writeFileOutput = `\n# 最终稿\n\n正文\n`; + mockExecuteSkill.mockImplementation(async () => { + streamHandler?.({ payload: { type: "text_delta", text: "流式旧内容" } }); + streamHandler?.({ payload: { type: "final_done" } }); + return { + success: true, + output: writeFileOutput, + steps_completed: [], + }; + }); + + const handled = await tryExecuteSlashSkillCommand({ + command: { + skillName: "social_post_with_cover", + userInput: "写一篇春季上新文案", + }, + rawContent: "/social_post_with_cover 写一篇春季上新文案", + assistantMsgId: "assistant-1", + providerType: "anthropic", + model: "claude-sonnet-4-20250514", + ensureSession: async () => "session-1", + setMessages: store.setMessages, + setIsSending: vi.fn(), + setCurrentAssistantMsgId: vi.fn(), + setStreamUnlisten: vi.fn(), + setActiveSessionIdForStop: vi.fn(), + isExecutionCancelled: () => false, + playTypewriterSound: vi.fn(), + playToolcallSound: vi.fn(), + onWriteFile, + }); + + expect(handled).toBe(true); + expect(store.getMessages()[0]?.content).toBe(writeFileOutput); + expect(store.getMessages()[0]?.contentParts).toEqual([ + { type: "text", text: writeFileOutput }, + ]); + expect(onWriteFile).not.toHaveBeenCalled(); + }); + + it("当社媒结果无 write_file 时应走前端兜底写入", async () => { + const store = createMessageStore([buildBaseMessage()]); + const onWriteFile = vi.fn(); + + mockExecuteSkill.mockResolvedValue({ + success: true, + output: "# 标题\n\n正文内容", + steps_completed: [], + }); + + const handled = await tryExecuteSlashSkillCommand({ + command: { + skillName: "social_post_with_cover", + userInput: "新品发布", + }, + rawContent: "/social_post_with_cover 新品发布", + assistantMsgId: "assistant-1", + providerType: "anthropic", + model: "claude-sonnet-4-20250514", + ensureSession: async () => "session-1", + setMessages: store.setMessages, + setIsSending: vi.fn(), + setCurrentAssistantMsgId: vi.fn(), + setStreamUnlisten: vi.fn(), + setActiveSessionIdForStop: vi.fn(), + isExecutionCancelled: () => false, + playTypewriterSound: vi.fn(), + playToolcallSound: vi.fn(), + onWriteFile, + }); + + expect(handled).toBe(true); + expect(store.getMessages()[0]?.content).toBe("# 标题\n\n正文内容"); + expect(onWriteFile).toHaveBeenCalledTimes(1); + const [contentArg, filePathArg] = onWriteFile.mock.calls[0]; + expect(contentArg).toBe("# 标题\n\n正文内容"); + expect(filePathArg).toMatch(/^social-posts\/\d{8}-\d{6}-[a-z0-9-]+-[a-z0-9]{3,6}\.md$/); + }); + + it("非社媒技能在无 write_file 时不应触发兜底写入", async () => { + const store = createMessageStore([buildBaseMessage()]); + const onWriteFile = vi.fn(); + mockListExecutableSkills.mockResolvedValue([ + { + name: "other_skill", + display_name: "other_skill", + description: "other", + execution_mode: "prompt", + has_workflow: false, + }, + ]); + mockExecuteSkill.mockResolvedValue({ + success: true, + output: "普通文本输出", + steps_completed: [], + }); + + const handled = await tryExecuteSlashSkillCommand({ + command: { + skillName: "other_skill", + userInput: "输出内容", + }, + rawContent: "/other_skill 输出内容", + assistantMsgId: "assistant-1", + providerType: "anthropic", + model: "claude-sonnet-4-20250514", + ensureSession: async () => "session-1", + setMessages: store.setMessages, + setIsSending: vi.fn(), + setCurrentAssistantMsgId: vi.fn(), + setStreamUnlisten: vi.fn(), + setActiveSessionIdForStop: vi.fn(), + isExecutionCancelled: () => false, + playTypewriterSound: vi.fn(), + playToolcallSound: vi.fn(), + onWriteFile, + }); + + expect(handled).toBe(true); + expect(onWriteFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/hooks/skillCommand.ts b/src/components/agent/chat/hooks/skillCommand.ts index c359cc282..6b834f9b7 100644 --- a/src/components/agent/chat/hooks/skillCommand.ts +++ b/src/components/agent/chat/hooks/skillCommand.ts @@ -42,6 +42,34 @@ const VALID_ACTION_TYPES = new Set([ "ask_user", "elicitation", ]); +const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; +const WRITE_FILE_TAG_REGEX = + /[\s\S]*?<\/write_file>/i; + +function hasWriteFileTag(content: string | undefined): boolean { + if (!content) { + return false; + } + return WRITE_FILE_TAG_REGEX.test(content); +} + +function buildSocialPostSlug(seed: string): string { + const normalized = seed + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 24); + return normalized || "post"; +} + +function buildSocialPostFallbackPath(seed: string, assistantMsgId: string): string { + const now = new Date(); + const format2 = (value: number) => String(value).padStart(2, "0"); + const timestamp = `${now.getFullYear()}${format2(now.getMonth() + 1)}${format2(now.getDate())}-${format2(now.getHours())}${format2(now.getMinutes())}${format2(now.getSeconds())}`; + const slug = buildSocialPostSlug(seed); + const suffix = assistantMsgId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 6) || "run"; + return `social-posts/${timestamp}-${slug}-${suffix.toLowerCase()}.md`; +} /** * 解析 slash skill 命令。 @@ -570,6 +598,8 @@ export async function tryExecuteSlashSkillCommand( : null; const failureText = failure ? formatSkillFailureMessage(failure) : ""; + const resultHasWriteFile = hasWriteFileTag(result.output); + const shouldForceResultOutput = !failure && resultHasWriteFile; const finalContent = failure ? hasStreamedContent @@ -577,7 +607,9 @@ export async function tryExecuteSlashSkillCommand( ${failureText}` : failureText - : hasStreamedContent + : shouldForceResultOutput + ? result.output || "Skill 执行完成" + : hasStreamedContent ? accumulatedContent : result.output || "Skill 执行完成"; @@ -591,7 +623,14 @@ ${failureText}` prev.map((msg) => { if (msg.id !== assistantMsgId) return msg; - const nextParts = [...(msg.contentParts || [])]; + const nextParts = shouldForceResultOutput + ? [ + ...(msg.contentParts || []).filter( + (part) => part.type !== "text" && part.type !== "thinking", + ), + { type: "text" as const, text: finalContent }, + ] + : [...(msg.contentParts || [])]; if (nextParts.length === 0 && finalContent) { nextParts.push({ type: "text", text: finalContent }); } @@ -606,6 +645,18 @@ ${failureText}` }), ); + if ( + !failure && + command.skillName === SOCIAL_ARTICLE_SKILL_KEY && + onWriteFile && + finalContent.trim().length > 0 && + !hasWriteFileTag(finalContent) + ) { + const seed = command.userInput || rawContent; + const fallbackPath = buildSocialPostFallbackPath(seed, assistantMsgId); + onWriteFile(finalContent, fallbackPath); + } + cleanup(); return true; } catch (error) { diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 2ed4fea9a..3e03b4282 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -19,8 +19,10 @@ import { stopAsterSession, type AgentProcessStatus, type SessionInfo, + type SkillInfo, type StreamEvent, } from "@/lib/api/agent"; +import { skillsApi } from "@/lib/api/skills"; import { A2UIFormAPI } from "@/lib/api/a2uiForm"; import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; import { @@ -675,23 +677,34 @@ export function useAgentChat(options: UseAgentChatOptions) { const createFreshSession = async (): Promise => { try { - // TEMPORARY FIX: Disable skills integration due to API type mismatch (Backend expects []SystemMessage, Client sends String) - // const [claudeSkills, proxyCastSkills] = await Promise.all([ - // skillsApi.getAll("claude").catch(() => []), - // skillsApi.getInstalledProxyCastSkills().catch(() => []), - // ]); + const [allProxycastSkills, localInstalledSkills] = await Promise.all([ + skillsApi.getAll("proxycast").catch(() => []), + skillsApi.getInstalledProxyCastSkills().catch(() => []), + ]); + const detailsByName = new Map(); - // const details: SkillInfo[] = claudeSkills.filter(s => s.installed).map(s => ({ - // name: s.name, - // description: s.description, - // path: s.directory ? `~/.claude/skills/${s.directory}/SKILL.md` : undefined, - // })); + allProxycastSkills + .filter((skill) => skill.installed) + .forEach((skill) => { + const skillName = (skill.directory || skill.key || skill.name || "").trim(); + if (!skillName) return; + detailsByName.set(skillName, { + name: skillName, + description: skill.description || undefined, + path: `~/.proxycast/skills/${skillName}/SKILL.md`, + }); + }); - // proxyCastSkills.forEach(name => { - // if (!details.find(d => d.name === name)) { - // details.push({ name, path: `~/.proxycast/skills/${name}/SKILL.md` }); - // } - // }); + localInstalledSkills.forEach((name) => { + const skillName = (name || "").trim(); + if (!skillName || detailsByName.has(skillName)) return; + detailsByName.set(skillName, { + name: skillName, + path: `~/.proxycast/skills/${skillName}/SKILL.md`, + }); + }); + + const details = Array.from(detailsByName.values()); // Create new session with CURRENT provider/model as baseline // 传递 systemPrompt 用于内容创作等场景 @@ -701,7 +714,7 @@ export function useAgentChat(options: UseAgentChatOptions) { resolvedWorkspaceId, model || undefined, systemPrompt, // 传递系统提示词 - undefined, // details.length > 0 ? details : undefined + details.length > 0 ? details : undefined, ); setSessionId(response.session_id); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 56789318e..944832d57 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -16,6 +16,8 @@ const { mockParseStreamEvent, mockSafeListen, mockToast, + mockParseSkillSlashCommand, + mockTryExecuteSlashSkillCommand, } = vi.hoisted(() => ({ mockInitAsterAgent: vi.fn(), mockSendAsterMessageStream: vi.fn(), @@ -35,6 +37,8 @@ const { info: vi.fn(), warning: vi.fn(), }, + mockParseSkillSlashCommand: vi.fn(() => null), + mockTryExecuteSlashSkillCommand: vi.fn(async () => false), })); vi.mock("@/lib/api/agent", () => ({ @@ -59,6 +63,11 @@ vi.mock("sonner", () => ({ toast: mockToast, })); +vi.mock("./skillCommand", () => ({ + parseSkillSlashCommand: mockParseSkillSlashCommand, + tryExecuteSlashSkillCommand: mockTryExecuteSlashSkillCommand, +})); + import { useAsterAgentChat } from "./useAsterAgentChat"; interface HookHarness { @@ -147,6 +156,8 @@ beforeEach(() => { mockConfirmAsterAction.mockResolvedValue(undefined); mockSubmitAsterElicitationResponse.mockResolvedValue(undefined); mockSafeListen.mockResolvedValue(() => {}); + mockParseSkillSlashCommand.mockReturnValue(null); + mockTryExecuteSlashSkillCommand.mockResolvedValue(false); }); afterEach(() => { @@ -154,6 +165,42 @@ afterEach(() => { sessionStorage.clear(); }); +describe("useAsterAgentChat 首页新会话", () => { + it("clearMessages 后重新进入同工作区不应恢复旧话题", async () => { + const workspaceId = "ws-home-clear"; + const sessionId = "session-home-clear"; + seedSession(workspaceId, sessionId); + + let harness = mountHook(workspaceId); + + try { + await flushEffects(); + act(() => { + harness.getValue().clearMessages({ showToast: false }); + }); + await flushEffects(); + + expect(harness.getValue().sessionId).toBeNull(); + expect(harness.getValue().messages).toEqual([]); + expect(sessionStorage.getItem(`aster_curr_sessionId_${workspaceId}`)).toBe("null"); + expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe("[]"); + expect(localStorage.getItem(`aster_last_sessionId_${workspaceId}`)).toBe("null"); + } finally { + harness.unmount(); + } + + harness = mountHook(workspaceId); + + try { + await flushEffects(); + expect(harness.getValue().sessionId).toBeNull(); + expect(harness.getValue().messages).toEqual([]); + } finally { + harness.unmount(); + } + }); +}); + describe("useAsterAgentChat.confirmAction", () => { it("tool_confirmation 应调用 confirmAsterAction", async () => { const workspaceId = "ws-tool"; @@ -239,6 +286,71 @@ describe("useAsterAgentChat.confirmAction", () => { }); }); +describe("useAsterAgentChat slash skill 执行链路", () => { + it("命中 slash skill 时应走 execute_skill 分支而非 chat_stream", async () => { + const workspaceId = "ws-slash-skill"; + const harness = mountHook(workspaceId); + + mockParseSkillSlashCommand.mockReturnValue({ + skillName: "social_post_with_cover", + userInput: "写一篇春季新品文案", + }); + mockTryExecuteSlashSkillCommand.mockResolvedValue(true); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().sendMessage( + "/social_post_with_cover 写一篇春季新品文案", + [], + false, + false, + false, + "react", + ); + }); + + expect(mockParseSkillSlashCommand).toHaveBeenCalledWith( + "/social_post_with_cover 写一篇春季新品文案", + ); + expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1); + expect(mockSendAsterMessageStream).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); + + it("slash skill 未处理时应回退到 chat_stream", async () => { + const workspaceId = "ws-slash-fallback"; + const harness = mountHook(workspaceId); + + mockParseSkillSlashCommand.mockReturnValue({ + skillName: "social_post_with_cover", + userInput: "写一篇春季新品文案", + }); + mockTryExecuteSlashSkillCommand.mockResolvedValue(false); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().sendMessage( + "/social_post_with_cover 写一篇春季新品文案", + [], + false, + false, + false, + "react", + ); + }); + + expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1); + expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1); + } finally { + harness.unmount(); + } + }); +}); + describe("useAsterAgentChat action_required 渲染链路", () => { it("仅收到 Ask 工具调用时应兜底渲染提问面板", async () => { const workspaceId = "ws-ask-fallback"; @@ -1678,6 +1790,37 @@ describe("useAsterAgentChat 兼容接口", () => { } }); + it("triggerAIGuide 应使用工作区已选模型发送请求", async () => { + const workspaceId = "ws-guide-selected-model"; + const selectedProvider = "gemini"; + const selectedModel = "gemini-2.5-pro"; + localStorage.setItem( + `agent_pref_provider_${workspaceId}`, + JSON.stringify(selectedProvider), + ); + localStorage.setItem( + `agent_pref_model_${workspaceId}`, + JSON.stringify(selectedModel), + ); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().triggerAIGuide("请输出一版社媒主稿"); + }); + + expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1); + expect(mockSendAsterMessageStream.mock.calls[0]?.[5]).toMatchObject({ + provider_id: selectedProvider, + model_name: selectedModel, + }); + } finally { + harness.unmount(); + } + }); + it("renameTopic 应调用后端并刷新话题标题", async () => { const createdAt = Math.floor(Date.now() / 1000); mockListAsterSessions diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index c86ccd0e6..73e0e2593 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -27,6 +27,7 @@ import { type ContextTraceStep, type AsterSessionInfo, type AsterExecutionStrategy, + type AutoContinueRequestPayload, type ToolResultImage, } from "@/lib/api/agent"; import { @@ -42,6 +43,10 @@ import { type Question, } from "../types"; import { activityLogger } from "@/components/content-creator/utils/activityLogger"; +import { + parseSkillSlashCommand, + tryExecuteSlashSkillCommand, +} from "./skillCommand"; /** 话题信息 */ export interface Topic { @@ -60,6 +65,17 @@ interface UseAsterAgentChatOptions { workspaceId: string; } +interface SendMessageObserver { + onTextDelta?: (delta: string, accumulated: string) => void; + onComplete?: (content: string) => void; + onError?: (message: string) => void; +} + +interface SendMessageOptions { + purpose?: Message["purpose"]; + observer?: SendMessageObserver; +} + const normalizeExecutionStrategy = ( value?: string | null, ): AsterExecutionStrategy => @@ -1519,11 +1535,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { skipUserMessage = false, executionStrategyOverride?: AsterExecutionStrategy, modelOverride?: string, + autoContinue?: AutoContinueRequestPayload, + options?: SendMessageOptions, ) => { const effectiveExecutionStrategy = executionStrategyOverride || executionStrategy; const effectiveProviderType = providerTypeRef.current; const effectiveModel = modelOverride?.trim() || modelRef.current; + const observer = options?.observer; + const messagePurpose = options?.purpose; // 助手消息占位符 const assistantMsgId = crypto.randomUUID(); const assistantMsg: Message = { @@ -1534,6 +1554,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { isThinking: true, thinkingContent: "思考中...", contentParts: [], + purpose: messagePurpose, }; if (skipUserMessage) { @@ -1546,12 +1567,47 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { content, images: images.length > 0 ? images : undefined, timestamp: new Date(), + purpose: messagePurpose, }; setMessages((prev) => [...prev, userMsg, assistantMsg]); } setIsSending(true); currentAssistantMsgIdRef.current = assistantMsgId; + if (!skipUserMessage) { + const parsedSkillCommand = parseSkillSlashCommand(content); + if (parsedSkillCommand) { + const skillHandled = await tryExecuteSlashSkillCommand({ + command: parsedSkillCommand, + rawContent: content, + assistantMsgId, + providerType: effectiveProviderType, + model: effectiveModel || undefined, + ensureSession, + setMessages, + setIsSending, + setCurrentAssistantMsgId: (id) => { + currentAssistantMsgIdRef.current = id; + }, + setStreamUnlisten: (unlistenFn) => { + unlistenRef.current = unlistenFn; + }, + setActiveSessionIdForStop: (sessionIdForStop) => { + currentStreamingSessionIdRef.current = sessionIdForStop; + }, + isExecutionCancelled: () => + currentAssistantMsgIdRef.current !== assistantMsgId, + playTypewriterSound, + playToolcallSound, + onWriteFile, + }); + + if (skillHandled) { + return; + } + } + } + let accumulatedContent = ""; let unlisten: UnlistenFn | null = null; let requestLogId: string | null = null; @@ -1584,6 +1640,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { executionStrategy: effectiveExecutionStrategy, contentLength: content.trim().length, skipUserMessage, + autoContinueEnabled: autoContinue?.enabled ?? false, + autoContinue: autoContinue?.enabled ? autoContinue : undefined, }, }); @@ -1671,6 +1729,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { switch (data.type) { case "text_delta": accumulatedContent += data.text; + observer?.onTextDelta?.(data.text, accumulatedContent); playTypewriterSound(); setMessages((prev) => prev.map((msg) => @@ -1986,7 +2045,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ); break; - case "final_done": + case "final_done": { if (requestLogId && !requestFinished) { requestFinished = true; activityLogger.updateLog(requestLogId, { @@ -1996,13 +2055,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { description: `请求完成,工具调用 ${toolLogIdByToolId.size} 次`, }); } + const finalContent = accumulatedContent || "(无响应)"; + observer?.onComplete?.(finalContent); setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...msg, isThinking: false, - content: accumulatedContent || "(无响应)", + content: finalContent, } : msg, ), @@ -2016,6 +2077,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { unlisten = null; } break; + } case "error": if (requestLogId && !requestFinished) { @@ -2027,6 +2089,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { error: data.message, }); } + observer?.onError?.(data.message); if ( data.message.includes("429") || data.message.toLowerCase().includes("rate limit") @@ -2097,6 +2160,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { providerConfig, effectiveExecutionStrategy, webSearch, + autoContinue, ); } catch (error) { if (requestLogId && !requestFinished) { @@ -2110,6 +2174,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { } console.error("[AsterChat] 发送失败:", error); const errMsg = error instanceof Error ? error.message : String(error); + observer?.onError?.(errMsg); if ( errMsg.includes("429") || errMsg.toLowerCase().includes("rate limit") @@ -2344,6 +2409,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ) => { const { showToast = true, toastMessage = "新话题已创建" } = options; + const scopedSessionKey = getScopedSessionKey(); + const scopedPersistedSessionKey = getScopedPersistedSessionKey(); + const scopedMessagesKey = getScopedMessagesKey(); + setMessages([]); setSessionId(null); setPendingActions([]); @@ -2353,11 +2422,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { currentAssistantMsgIdRef.current = null; currentStreamingSessionIdRef.current = null; + saveTransient(scopedSessionKey, null); + savePersisted(scopedPersistedSessionKey, null); + saveTransient(scopedMessagesKey, []); + if (showToast) { toast.success(toastMessage); } }, - [], + [getScopedMessagesKey, getScopedPersistedSessionKey, getScopedSessionKey], ); // 删除消息 diff --git a/src/components/agent/chat/hooks/useContentSync.test.tsx b/src/components/agent/chat/hooks/useContentSync.test.tsx new file mode 100644 index 000000000..866d65872 --- /dev/null +++ b/src/components/agent/chat/hooks/useContentSync.test.tsx @@ -0,0 +1,120 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUpdateContent } = vi.hoisted(() => ({ + mockUpdateContent: vi.fn(), +})); + +vi.mock("@/lib/api/project", () => ({ + updateContent: mockUpdateContent, +})); + +import { useContentSync } from "./useContentSync"; + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +interface HookHarness { + getValue: () => ReturnType; + unmount: () => void; +} + +function mountHook(options?: Parameters[0]): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useContentSync(options); + return null; + } + + act(() => { + root.render(); + }); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("useContentSync", () => { + let harness: HookHarness | null = null; + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + mockUpdateContent.mockReset(); + mockUpdateContent.mockResolvedValue(undefined); + }); + + afterEach(() => { + harness?.unmount(); + harness = null; + vi.clearAllTimers(); + vi.useRealTimers(); + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("相同内容在防抖期间不应重复推迟同步", async () => { + harness = mountHook({ debounceMs: 2000, autoRetry: false }); + + act(() => { + harness?.getValue().syncContent("content-1", "hello world"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + act(() => { + harness?.getValue().syncContent("content-1", "hello world"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(999); + }); + expect(mockUpdateContent).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(mockUpdateContent).toHaveBeenCalledTimes(1); + expect(mockUpdateContent).toHaveBeenCalledWith("content-1", { + body: "hello world", + }); + }); + + it("卸载时应清理待执行的同步定时器", async () => { + harness = mountHook({ debounceMs: 2000, autoRetry: false }); + + act(() => { + harness?.getValue().syncContent("content-1", "hello world"); + }); + + harness.unmount(); + harness = null; + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mockUpdateContent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/hooks/useContentSync.ts b/src/components/agent/chat/hooks/useContentSync.ts index a0a9c2d1d..6437aebb0 100644 --- a/src/components/agent/chat/hooks/useContentSync.ts +++ b/src/components/agent/chat/hooks/useContentSync.ts @@ -4,7 +4,7 @@ * 提供防抖同步、状态管理和失败重试功能 */ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { updateContent } from "@/lib/api/project"; export type SyncStatus = "idle" | "syncing" | "success" | "error"; @@ -35,6 +35,8 @@ export function useContentSync( const [syncStatus, setSyncStatus] = useState("idle"); const syncTimeoutRef = useRef>(); const retryTimeoutRef = useRef>(); + const statusResetTimeoutRef = useRef>(); + const isSyncingRef = useRef(false); const lastSyncDataRef = useRef<{ contentId: string; body: string } | null>( null, ); @@ -43,29 +45,56 @@ export function useContentSync( body: string; } | null>(null); + const clearTimers = useCallback(() => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = undefined; + } + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = undefined; + } + if (statusResetTimeoutRef.current) { + clearTimeout(statusResetTimeoutRef.current); + statusResetTimeoutRef.current = undefined; + } + }, []); + const syncContent = useCallback( (contentId: string, body: string) => { - // 与最近一次成功同步内容一致时,跳过重复同步 - if ( + const isSameAsLastSuccess = lastSuccessfulSyncRef.current?.contentId === contentId && - lastSuccessfulSyncRef.current.body === body + lastSuccessfulSyncRef.current.body === body; + if (isSameAsLastSuccess) { + return; + } + + const isSameAsLatestPending = + lastSyncDataRef.current?.contentId === contentId && + lastSyncDataRef.current.body === body; + if ( + isSameAsLatestPending && + (Boolean(syncTimeoutRef.current) || + Boolean(retryTimeoutRef.current) || + isSyncingRef.current) ) { return; } - // 保存最后的同步数据(用于重试) lastSyncDataRef.current = { contentId, body }; - // 清除之前的定时器 if (syncTimeoutRef.current) { clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = undefined; } if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = undefined; } - // 防抖:延迟后同步 syncTimeoutRef.current = setTimeout(async () => { + syncTimeoutRef.current = undefined; + isSyncingRef.current = true; setSyncStatus("syncing"); try { @@ -73,8 +102,11 @@ export function useContentSync( lastSuccessfulSyncRef.current = { contentId, body }; setSyncStatus("success"); - // 3 秒后重置状态 - setTimeout(() => { + if (statusResetTimeoutRef.current) { + clearTimeout(statusResetTimeoutRef.current); + } + statusResetTimeoutRef.current = setTimeout(() => { + statusResetTimeoutRef.current = undefined; setSyncStatus((current) => current === "success" ? "idle" : current, ); @@ -83,9 +115,9 @@ export function useContentSync( console.error("同步内容失败:", error); setSyncStatus("error"); - // 自动重试 if (autoRetry && lastSyncDataRef.current) { retryTimeoutRef.current = setTimeout(() => { + retryTimeoutRef.current = undefined; if (lastSyncDataRef.current) { console.log("[useContentSync] 重试同步..."); syncContent( @@ -95,6 +127,8 @@ export function useContentSync( } }, retryDelayMs); } + } finally { + isSyncingRef.current = false; } }, debounceMs); }, @@ -103,13 +137,14 @@ export function useContentSync( const resetStatus = useCallback(() => { setSyncStatus("idle"); - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current); - } - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - } - }, []); + clearTimers(); + isSyncingRef.current = false; + }, [clearTimers]); + + useEffect(() => () => { + clearTimers(); + isSyncingRef.current = false; + }, [clearTimers]); return { syncContent, syncStatus, resetStatus }; } diff --git a/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx b/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx new file mode 100644 index 000000000..3d21735e0 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx @@ -0,0 +1,415 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + useThemeContextWorkspace, + type ThemeContextWorkspaceState, +} from "./useThemeContextWorkspace"; +import type { Message } from "../types"; + +const mockListContents = vi.hoisted(() => vi.fn()); +const mockGetContent = vi.hoisted(() => vi.fn()); +const mockSearchThemeContextWithWebSearch = vi.hoisted(() => vi.fn()); +const mockUseMaterials = vi.hoisted(() => vi.fn()); +const mockIsContentCreationTheme = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/project", () => ({ + listContents: mockListContents, + getContent: mockGetContent, +})); + +vi.mock("@/hooks/useMaterials", () => ({ + useMaterials: mockUseMaterials, +})); + +vi.mock("@/components/content-creator/utils/systemPrompt", () => ({ + isContentCreationTheme: mockIsContentCreationTheme, +})); + +vi.mock("../utils/contextSearch", () => ({ + searchThemeContextWithWebSearch: mockSearchThemeContextWithWebSearch, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + sessionStorage.clear(); + mockListContents.mockResolvedValue([]); + mockGetContent.mockResolvedValue({ body: "" }); + mockSearchThemeContextWithWebSearch.mockResolvedValue({ + title: "默认搜索上下文", + summary: "默认搜索摘要", + citations: [], + rawResponse: '{"title":"默认搜索上下文","summary":"默认搜索摘要","citations":[]}', + }); + mockUseMaterials.mockReturnValue({ materials: [], getContent: vi.fn().mockResolvedValue("") }); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + sessionStorage.clear(); +}); + +interface ProbeProps { + projectId?: string; + activeTheme: string; + messages: Message[]; + providerType?: string; + model?: string; + onSnapshot: (value: ThemeContextWorkspaceState) => void; +} + +function Probe({ + projectId, + activeTheme, + messages, + providerType = "openai", + model = "gpt-4o-mini", + onSnapshot, +}: ProbeProps) { + const state = useThemeContextWorkspace({ + projectId, + activeTheme, + messages, + providerType, + model, + }); + onSnapshot(state); + return null; +} + +async function flushEffects(times = 8) { + for (let index = 0; index < times; index += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +function mountProbe(props: ProbeProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + act(() => { + root.render(); + }); +} + +describe("useThemeContextWorkspace", () => { + it("非主题模式应禁用工作台上下文", async () => { + mockIsContentCreationTheme.mockReturnValue(false); + let snapshot: ThemeContextWorkspaceState | null = null; + + mountProbe({ + projectId: "project-a", + activeTheme: "general", + messages: [], + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(); + + expect(snapshot!.enabled).toBe(false); + expect(snapshot!.sidebarContextItems).toEqual([]); + expect(snapshot!.activeContextPrompt).toBe(""); + }); + + it("主题模式应自动加载 Top3 上下文并生成日志快照", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseMaterials.mockReturnValue({ + materials: [ + { id: "m1", name: "素材1", description: "desc", tags: ["a"] }, + { id: "m2", name: "素材2", description: "desc", tags: ["b"] }, + ], + getContent: vi.fn().mockResolvedValue("素材正文"), + }); + mockListContents.mockResolvedValue([ + { + id: "c1", + title: "历史稿1", + content_type: "article", + status: "done", + }, + ]); + + const messages: Message[] = [ + { + id: "msg-1", + role: "assistant", + content: "", + timestamp: new Date("2026-03-05T10:30:00.000Z"), + toolCalls: [ + { + id: "tool-1", + name: "research", + status: "completed", + startTime: new Date("2026-03-05T10:30:00.000Z"), + endTime: new Date("2026-03-05T10:30:01.500Z"), + }, + ], + }, + ]; + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-b", + activeTheme: "social-media", + messages, + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(12); + + expect(snapshot!.enabled).toBe(true); + expect(snapshot!.contextBudget.activeCount).toBe(3); + expect(snapshot!.activeContextPrompt).toContain("[生效上下文]"); + expect(snapshot!.activityLogs[0]?.name).toBe("research"); + expect((snapshot!.activityLogs[0]?.contextIds?.length || 0) > 0).toBe(true); + expect(snapshot!.activityLogs[0]?.messageId).toBe("msg-1"); + }); + + it("历史内容同标题应仅保留最新一条", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockListContents.mockResolvedValue([ + { + id: "content-old", + project_id: "project-dup-content", + title: "新帖子", + content_type: "post", + status: "draft", + order: 1, + word_count: 0, + created_at: 1700000000000, + updated_at: 1700000000000, + }, + { + id: "content-new", + project_id: "project-dup-content", + title: "新帖子", + content_type: "post", + status: "draft", + order: 2, + word_count: 0, + created_at: 1700000100000, + updated_at: 1700000200000, + }, + { + id: "content-other", + project_id: "project-dup-content", + title: "选题池", + content_type: "post", + status: "draft", + order: 3, + word_count: 0, + created_at: 1700000300000, + updated_at: 1700000400000, + }, + ]); + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-dup-content", + activeTheme: "social-media", + messages: [], + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(12); + + const contentItems = snapshot!.sidebarContextItems.filter( + (item) => item.source === "content", + ); + const sameTitleItems = contentItems.filter((item) => item.name === "新帖子"); + + expect(sameTitleItems).toHaveLength(1); + expect(sameTitleItems[0]?.id).toBe("content:content-new"); + expect(contentItems.map((item) => item.id)).toEqual( + expect.arrayContaining(["content:content-new", "content:content-other"]), + ); + }); + + it("应支持联网搜索生成上下文并自动激活", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockSearchThemeContextWithWebSearch.mockResolvedValue({ + title: "智能体泡沫观察", + summary: "2026 年市场对智能体基建和应用进入分化阶段,讨论聚焦落地成本、场景ROI与平台生态。", + citations: [ + { title: "官方博客", url: "https://example.com/blog" }, + ], + rawResponse: + '{"title":"智能体泡沫观察","summary":"2026 年市场对智能体基建和应用进入分化阶段,讨论聚焦落地成本、场景ROI与平台生态。","citations":[{"title":"官方博客","url":"https://example.com/blog"}]}' , + }); + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-search", + activeTheme: "social-media", + messages: [], + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(10); + + act(() => { + snapshot!.setContextSearchQuery("智能体泡沫 2026"); + }); + await flushEffects(4); + + await act(async () => { + await snapshot!.submitContextSearch(); + }); + await flushEffects(8); + + expect(mockSearchThemeContextWithWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "project-search", + projectId: "project-search", + query: "智能体泡沫 2026", + mode: "web", + }), + ); + expect(snapshot!.contextSearchQuery).toBe(""); + expect(snapshot!.sidebarContextItems[0]?.source).toBe("search"); + expect(snapshot!.sidebarContextItems[0]?.active).toBe(true); + expect(snapshot!.sidebarContextItems[0]?.previewText).toContain("2026 年市场"); + expect(snapshot!.sidebarContextItems[0]?.citations?.[0]?.url).toBe("https://example.com/blog"); + expect(snapshot!.activeContextPrompt).toContain("智能体泡沫观察"); + expect(snapshot!.activeContextPrompt).toContain("https://example.com/blog"); + }); + + it("prepareActiveContextPrompt 应加载本地正文并拼入提示词", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + const mockMaterialGetContent = vi.fn().mockResolvedValue("品牌手册正文,包含品牌定位、目标人群和传播语气。"); + mockUseMaterials.mockReturnValue({ + materials: [{ id: "m1", name: "品牌手册", description: "品牌资产", tags: [] }], + getContent: mockMaterialGetContent, + }); + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-material", + activeTheme: "social-media", + messages: [], + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(10); + + const prompt = await snapshot!.prepareActiveContextPrompt(); + + expect(mockMaterialGetContent).toHaveBeenCalledWith("m1"); + expect(prompt).toContain("品牌手册"); + expect(prompt).toContain("品牌手册正文"); + }); + + it("应兼容序列化后的时间字符串并正确格式化耗时", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseMaterials.mockReturnValue({ + materials: [{ id: "m1", name: "素材1", description: "desc", tags: [] }], + getContent: vi.fn().mockResolvedValue("素材正文"), + }); + mockListContents.mockResolvedValue([]); + + const messages = [ + { + id: "msg-2", + role: "assistant", + content: "", + timestamp: "2026-03-05T10:30:00.000Z", + toolCalls: [ + { + id: "tool-2", + name: "typesetting", + status: "completed", + startTime: "2026-03-05T10:30:00.000Z", + endTime: "2026-03-05T10:30:01.500Z", + }, + ], + }, + ] as unknown as Message[]; + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-c", + activeTheme: "social-media", + messages, + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(12); + + expect(snapshot!.activityLogs[0]?.durationLabel).toBe("1.5s"); + expect(snapshot!.activityLogs[0]?.timeLabel).not.toBe("--:--"); + }); + + it("应从工具参数与输出提取修改产物路径", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + + const messages = [ + { + id: "msg-3", + role: "assistant", + content: "", + timestamp: "2026-03-05T10:40:00.000Z", + toolCalls: [ + { + id: "tool-3", + name: "write_file", + status: "completed", + arguments: JSON.stringify({ + file_path: "social-posts/demo.md", + }), + result: { + success: true, + output: JSON.stringify({ + artifact_paths: [ + "social-posts/demo.md", + "social-posts/demo.publish-pack.json", + ], + }), + }, + startTime: "2026-03-05T10:40:00.000Z", + endTime: "2026-03-05T10:40:01.000Z", + }, + ], + }, + ] as unknown as Message[]; + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-artifact-path", + activeTheme: "social-media", + messages, + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(12); + + expect(snapshot!.activityLogs[0]?.artifactPaths).toEqual([ + "social-posts/demo.md", + "social-posts/demo.publish-pack.json", + ]); + }); +}); diff --git a/src/components/agent/chat/hooks/useThemeContextWorkspace.ts b/src/components/agent/chat/hooks/useThemeContextWorkspace.ts new file mode 100644 index 000000000..d02ffc03e --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeContextWorkspace.ts @@ -0,0 +1,1246 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getContent, listContents, type ContentListItem } from "@/lib/api/project"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { useMaterials } from "@/hooks/useMaterials"; +import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt"; +import type { Message } from "../types"; +import { + searchThemeContextWithWebSearch, + type SearchCitation, + type ThemeContextSearchMode, +} from "../utils/contextSearch"; + +const CONTEXT_SELECTION_KEY_PREFIX = "agent_active_context_ids_"; +const CONTEXT_MANUAL_SELECTION_KEY_PREFIX = "agent_manual_context_ids_"; +const GENERATED_CONTEXT_STORAGE_KEY_PREFIX = "agent_generated_contexts_"; +const DEFAULT_CONTEXT_ITEM_LIMIT = 12; +const DEFAULT_CONTEXT_TOKEN_LIMIT = 32000; +const DEFAULT_ACTIVE_CONTEXT_COUNT = 3; +const LOCAL_CONTEXT_PREVIEW_LENGTH = 900; +const SEARCH_CONTEXT_PREVIEW_LENGTH = 560; + +type ContextSource = "material" | "content" | "search"; + +interface GeneratedSearchContextItem { + id: string; + name: string; + source: "search"; + searchMode: ThemeContextSearchMode; + query: string; + summary: string; + citations: SearchCitation[]; + rawResponse?: string; + createdAt: number; +} + +interface ContextCatalogItem { + id: string; + name: string; + source: ContextSource; + normalizedText: string; + estimatedTokens: number; + previewText: string; + bodyText?: string; + query?: string; + searchMode?: ThemeContextSearchMode; + citations?: SearchCitation[]; + createdAt?: number; +} + +export interface SidebarContextItem { + id: string; + name: string; + source: ContextSource; + active: boolean; + searchMode?: ThemeContextSearchMode; + query?: string; + previewText?: string; + citations?: SearchCitation[]; + createdAt?: number; +} + +export interface SidebarActivityLog { + id: string; + name: string; + status: "running" | "completed" | "failed"; + timeLabel: string; + durationLabel?: string; + applyTarget?: string; + contextIds?: string[]; + inputSummary?: string; + outputSummary?: string; + runId?: string; + executionId?: string; + sessionId?: string; + artifactPaths?: string[]; + messageId?: string; + gateKey?: "idle" | "topic_select" | "write_mode" | "publish_confirm"; + source?: string; +} + +export interface ThemeContextWorkspaceState { + enabled: boolean; + contextSearchQuery: string; + setContextSearchQuery: (value: string) => void; + contextSearchMode: ThemeContextSearchMode; + setContextSearchMode: (value: ThemeContextSearchMode) => void; + contextSearchLoading: boolean; + contextSearchError: string | null; + contextSearchBlockedReason: string | null; + submitContextSearch: () => Promise; + addTextContext: (payload: { + content: string; + name?: string; + }) => Promise; + addLinkContext: (payload: { + url: string; + name?: string; + }) => Promise; + addFileContext: (payload: { + path: string; + name?: string; + }) => Promise; + sidebarContextItems: SidebarContextItem[]; + toggleContextActive: (contextId: string) => void; + getContextDetail: (contextId: string) => ContextCatalogItem | null; + contextBudget: { + activeCount: number; + activeCountLimit: number; + estimatedTokens: number; + tokenLimit: number; + }; + activityLogs: SidebarActivityLog[]; + activeContextPrompt: string; + prepareActiveContextPrompt: () => Promise; +} + +function buildContextStorageKey(projectId: string) { + return `${CONTEXT_SELECTION_KEY_PREFIX}${projectId}`; +} + +function buildManualContextStorageKey(projectId: string) { + return `${CONTEXT_MANUAL_SELECTION_KEY_PREFIX}${projectId}`; +} + +function buildGeneratedContextStorageKey(projectId: string) { + return `${GENERATED_CONTEXT_STORAGE_KEY_PREFIX}${projectId}`; +} + +function loadTransient(key: string, defaultValue: T): T { + try { + const raw = sessionStorage.getItem(key); + if (!raw) { + return defaultValue; + } + return JSON.parse(raw) as T; + } catch { + return defaultValue; + } +} + +function saveTransient(key: string, value: unknown) { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore + } +} + +function normalizeDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + return null; +} + +function formatLogTimeLabel(rawDate: unknown): string { + const date = normalizeDate(rawDate); + if (!date) { + return "--:--"; + } + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function truncateText(value: string, maxLength = 100): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength)}...`; +} + +function normalizeText(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateContextBody(value: string, source: ContextSource): string { + const normalized = normalizeText(value); + const maxLength = + source === "search" ? SEARCH_CONTEXT_PREVIEW_LENGTH : LOCAL_CONTEXT_PREVIEW_LENGTH; + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength)}...`; +} + +function estimateTokens(value: string, fallback = 120): number { + const normalized = normalizeText(value); + if (!normalized) { + return fallback; + } + return Math.max(fallback, Math.ceil(normalized.length / 4)); +} + +function resolveContextTitle(value: string, fallback: string): string { + const normalized = normalizeText(value); + if (!normalized) { + return fallback; + } + return normalized.length > 36 ? `${normalized.slice(0, 36)}...` : normalized; +} + +function resolvePathFileName(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const segments = normalized.split("/"); + const last = segments[segments.length - 1]?.trim(); + return last || "上下文文件"; +} + +function resolveContentTimestamp(content: ContentListItem): number { + if (typeof content.updated_at === "number" && Number.isFinite(content.updated_at)) { + return content.updated_at; + } + if (typeof content.created_at === "number" && Number.isFinite(content.created_at)) { + return content.created_at; + } + return 0; +} + +function dedupeContentsByTitle(contents: ContentListItem[]): ContentListItem[] { + const deduped = new Map(); + + contents.forEach((item) => { + const normalizedTitle = normalizeText(item.title || "").toLowerCase(); + const dedupeKey = normalizedTitle || `content:${item.id}`; + const existing = deduped.get(dedupeKey); + if (!existing) { + deduped.set(dedupeKey, item); + return; + } + if (resolveContentTimestamp(item) >= resolveContentTimestamp(existing)) { + deduped.set(dedupeKey, item); + } + }); + + return Array.from(deduped.values()); +} + +function formatDurationLabel( + startTime?: unknown, + endTime?: unknown, +): string | undefined { + const normalizedStart = normalizeDate(startTime); + const normalizedEnd = normalizeDate(endTime); + if (!normalizedStart || !normalizedEnd) { + return undefined; + } + + const durationMs = normalizedEnd.getTime() - normalizedStart.getTime(); + if (durationMs < 0) { + return undefined; + } + + if (durationMs < 1000) { + return `${durationMs}ms`; + } + + if (durationMs < 60000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + + return `${Math.floor(durationMs / 60000)}m${Math.round( + (durationMs % 60000) / 1000, + )}s`; +} + +const TOOL_ARTIFACT_KEYWORDS = [ + "path", + "file", + "filename", + "artifact", + "output", + "target", + "destination", +]; + +function isLikelyArtifactPath(value: string): boolean { + const normalized = value.trim(); + if (!normalized || normalized.length > 260) { + return false; + } + if (normalized.includes("\n")) { + return false; + } + const lower = normalized.toLowerCase(); + if ( + lower.startsWith("http://") || + lower.startsWith("https://") || + lower.startsWith("data:") + ) { + return false; + } + return /[\\/]/.test(normalized) || /\.[a-z0-9]{1,10}$/i.test(normalized); +} + +function collectArtifactPathFromValue(value: unknown, bucket: Set): void { + if (typeof value === "string") { + const candidate = value.trim(); + if (isLikelyArtifactPath(candidate)) { + bucket.add(candidate); + } + return; + } + if (Array.isArray(value)) { + value.forEach((item) => collectArtifactPathFromValue(item, bucket)); + return; + } + if (!value || typeof value !== "object") { + return; + } + + const record = value as Record; + Object.entries(record).forEach(([key, nestedValue]) => { + const lowerKey = key.toLowerCase(); + const shouldCollectDirectly = TOOL_ARTIFACT_KEYWORDS.some((keyword) => + lowerKey.includes(keyword), + ); + if (shouldCollectDirectly) { + collectArtifactPathFromValue(nestedValue, bucket); + return; + } + if (nestedValue && typeof nestedValue === "object") { + collectArtifactPathFromValue(nestedValue, bucket); + } + }); +} + +function tryParseJson(raw?: string): unknown { + if (!raw || typeof raw !== "string") { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function extractToolCallArtifactPaths( + argumentsRaw?: string, + outputRaw?: string, +): string[] { + const bucket = new Set(); + const parsedArgs = tryParseJson(argumentsRaw); + const parsedOutput = tryParseJson(outputRaw); + + if (parsedArgs) { + collectArtifactPathFromValue(parsedArgs, bucket); + } + if (parsedOutput) { + collectArtifactPathFromValue(parsedOutput, bucket); + } + + return Array.from(bucket); +} + +function resolveApplyTarget(toolName: string): string { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("cover") || + normalized.includes("image") || + normalized.includes("illustration") || + normalized.includes("poster") + ) { + return "封面/插图"; + } + if ( + normalized.includes("typesetting") || + normalized.includes("format") || + normalized.includes("review") + ) { + return "主稿排版"; + } + if (normalized.includes("publish")) { + return "发布素材"; + } + return "主稿内容"; +} + +function resolveContextSourceLabel( + source: ContextSource, + searchMode?: ThemeContextSearchMode, +): string { + if (source === "material") { + return "素材库"; + } + if (source === "content") { + return "历史内容"; + } + return searchMode === "social" ? "社交媒体" : "网络搜索"; +} + +function buildGeneratedContextId( + searchMode: ThemeContextSearchMode, + query: string, +): string { + const normalizedQuery = normalizeText(query).toLowerCase(); + return `search:${searchMode}:${normalizedQuery}`; +} + +function buildActiveContextPromptText( + items: ContextCatalogItem[], + contextBodyById: Record, +): string { + if (items.length === 0) { + return ""; + } + + const blocks = items.map((item, index) => { + const body = + contextBodyById[item.id] || + item.bodyText || + truncateContextBody(item.previewText || item.name, item.source); + const lines = [ + `${index + 1}. [${resolveContextSourceLabel(item.source, item.searchMode)}] ${item.name}`, + ]; + + if (item.source === "search" && item.query) { + lines.push(`检索词:${item.query}`); + } + + if (body) { + lines.push(`摘要:${body}`); + } + + if (item.source === "search" && item.citations && item.citations.length > 0) { + lines.push("来源:"); + item.citations.slice(0, 5).forEach((citation) => { + lines.push(`- ${citation.title} ${citation.url}`); + }); + } + + return lines.join("\n"); + }); + + return [ + "[生效上下文]", + blocks.join("\n\n"), + "", + "[要求]", + "优先基于上述上下文作答;若上下文不足,请明确指出不足点。", + ].join("\n"); +} + +function resolveContextTokenEstimate( + item: ContextCatalogItem, + contextBodyById: Record, +): number { + const cachedBody = contextBodyById[item.id]; + const sourceText = cachedBody || item.bodyText || item.previewText || item.name; + return estimateTokens(sourceText, item.estimatedTokens); +} + +interface UseThemeContextWorkspaceOptions { + projectId?: string; + activeTheme: string; + messages: Message[]; + providerType?: string | null; + model?: string | null; +} + +export function useThemeContextWorkspace({ + projectId, + activeTheme, + messages, + providerType, + model, +}: UseThemeContextWorkspaceOptions): ThemeContextWorkspaceState { + const enabled = isContentCreationTheme(activeTheme); + const normalizedProjectId = normalizeProjectId(projectId); + const contextProjectId = enabled ? normalizedProjectId : null; + + const materialWorkspace = useMaterials(contextProjectId); + const materials = materialWorkspace.materials; + const getMaterialContent = materialWorkspace.getContent; + const uploadMaterial = materialWorkspace.upload; + + const [projectContents, setProjectContents] = useState([]); + const [generatedSearchContexts, setGeneratedSearchContexts] = useState< + GeneratedSearchContextItem[] + >([]); + const [contextSearchQuery, setContextSearchQueryState] = useState(""); + const [contextSearchMode, setContextSearchModeState] = + useState("web"); + const [contextSearchLoading, setContextSearchLoading] = useState(false); + const [contextSearchError, setContextSearchError] = useState(null); + const [activeContextIds, setActiveContextIds] = useState([]); + const [manualContextIds, setManualContextIds] = useState([]); + const [contextBodyById, setContextBodyById] = useState>({}); + const contextSelectionInitializedRef = useRef(null); + const contextSnapshotByToolCallRef = useRef>({}); + const contextBodyByIdRef = useRef>({}); + const contextBodyPromiseRef = useRef>>({}); + const [generatedContextReadyProjectId, setGeneratedContextReadyProjectId] = + useState(null); + + useEffect(() => { + contextBodyByIdRef.current = contextBodyById; + }, [contextBodyById]); + + useEffect(() => { + contextBodyPromiseRef.current = {}; + setContextBodyById({}); + setContextSearchError(null); + }, [contextProjectId]); + + useEffect(() => { + if (!contextProjectId) { + setProjectContents([]); + return; + } + + let cancelled = false; + listContents(contextProjectId) + .then((items) => { + if (!cancelled) { + setProjectContents(items); + } + }) + .catch((error) => { + console.warn("[useThemeContextWorkspace] 加载上下文内容失败:", error); + if (!cancelled) { + setProjectContents([]); + } + }); + + return () => { + cancelled = true; + }; + }, [contextProjectId]); + + useEffect(() => { + setGeneratedContextReadyProjectId(null); + + if (!contextProjectId) { + setGeneratedSearchContexts([]); + return; + } + + const storageKey = buildGeneratedContextStorageKey(contextProjectId); + const stored = loadTransient(storageKey, []); + setGeneratedSearchContexts(Array.isArray(stored) ? stored : []); + setGeneratedContextReadyProjectId(contextProjectId); + }, [contextProjectId]); + + useEffect(() => { + if (!contextProjectId) { + return; + } + const storageKey = buildGeneratedContextStorageKey(contextProjectId); + saveTransient(storageKey, generatedSearchContexts); + }, [contextProjectId, generatedSearchContexts]); + + const contextCatalog = useMemo(() => { + if (!enabled) { + return []; + } + + const generatedItems: ContextCatalogItem[] = [...generatedSearchContexts] + .sort((left, right) => right.createdAt - left.createdAt) + .map((item) => ({ + id: item.id, + name: item.name, + source: "search" as const, + searchMode: item.searchMode, + query: item.query, + citations: item.citations, + previewText: truncateContextBody(item.summary, "search"), + bodyText: item.summary, + normalizedText: normalizeText( + `${item.name} ${item.query} ${item.summary} ${item.citations + .map((citation) => `${citation.title} ${citation.url}`) + .join(" ")}`, + ).toLowerCase(), + estimatedTokens: estimateTokens(item.summary, 180), + createdAt: item.createdAt, + })); + + const materialItems: ContextCatalogItem[] = materials.map((material) => { + const snippet = `${material.name} ${material.description || ""} ${(material.tags || []).join(" ")}`; + const previewText = truncateContextBody( + `${material.description || ""} ${(material.tags || []).join(" ")}`, + "material", + ); + return { + id: `material:${material.id}`, + name: material.name, + source: "material", + normalizedText: normalizeText(snippet).toLowerCase(), + previewText, + estimatedTokens: estimateTokens(snippet, 120), + }; + }); + + const dedupedProjectContents = dedupeContentsByTitle(projectContents); + const contentItems: ContextCatalogItem[] = dedupedProjectContents.map((content) => { + const snippet = `${content.title} ${content.content_type || ""} ${content.status || ""}`; + return { + id: `content:${content.id}`, + name: content.title, + source: "content", + normalizedText: normalizeText(snippet).toLowerCase(), + previewText: truncateContextBody(snippet, "content"), + estimatedTokens: 320, + createdAt: resolveContentTimestamp(content) || undefined, + }; + }); + + return [...generatedItems, ...materialItems, ...contentItems]; + }, [enabled, generatedSearchContexts, materials, projectContents]); + + const contextCatalogById = useMemo( + () => new Map(contextCatalog.map((item) => [item.id, item])), + [contextCatalog], + ); + + const orderedActiveContextItems = useMemo( + () => + activeContextIds + .map((id) => contextCatalogById.get(id)) + .filter((item): item is ContextCatalogItem => Boolean(item)), + [activeContextIds, contextCatalogById], + ); + + useEffect(() => { + if (!contextProjectId) { + contextSelectionInitializedRef.current = null; + setActiveContextIds([]); + setManualContextIds([]); + return; + } + + if (generatedContextReadyProjectId !== contextProjectId) { + return; + } + + if (contextSelectionInitializedRef.current === contextProjectId) { + return; + } + + const storageKey = buildContextStorageKey(contextProjectId); + const manualStorageKey = buildManualContextStorageKey(contextProjectId); + const persisted = loadTransient(storageKey, []); + const manualPersisted = loadTransient(manualStorageKey, []); + const validPersisted = persisted.filter((id) => contextCatalog.some((item) => item.id === id)); + const validManualPersisted = manualPersisted.filter((id) => + contextCatalog.some((item) => item.id === id), + ); + + if (validPersisted.length > 0) { + setActiveContextIds(validPersisted); + setManualContextIds( + validManualPersisted.filter((id) => validPersisted.includes(id)), + ); + } else { + setActiveContextIds( + contextCatalog.slice(0, DEFAULT_ACTIVE_CONTEXT_COUNT).map((item) => item.id), + ); + setManualContextIds([]); + } + contextSelectionInitializedRef.current = contextProjectId; + }, [contextCatalog, contextProjectId, generatedContextReadyProjectId]); + + useEffect(() => { + if (!enabled || !contextProjectId) { + return; + } + + if (manualContextIds.length > 0 || contextCatalog.length === 0) { + return; + } + + setActiveContextIds((previous) => { + const validSet = new Set(contextCatalog.map((item) => item.id)); + const normalized = previous.filter((id) => validSet.has(id)); + const targetCount = Math.min(DEFAULT_ACTIVE_CONTEXT_COUNT, contextCatalog.length); + if (normalized.length >= targetCount) { + return normalized; + } + + const next = [...normalized]; + for (const item of contextCatalog) { + if (next.length >= targetCount) { + break; + } + if (!next.includes(item.id)) { + next.push(item.id); + } + } + return next; + }); + }, [contextCatalog, contextProjectId, enabled, manualContextIds.length]); + + useEffect(() => { + if (!contextProjectId) { + return; + } + const storageKey = buildContextStorageKey(contextProjectId); + const manualStorageKey = buildManualContextStorageKey(contextProjectId); + saveTransient(storageKey, activeContextIds); + saveTransient(manualStorageKey, manualContextIds); + }, [activeContextIds, contextProjectId, manualContextIds]); + + useEffect(() => { + if (!enabled) { + return; + } + + setActiveContextIds((previous) => { + const validSet = new Set(contextCatalog.map((item) => item.id)); + const validActive = previous.filter((id) => validSet.has(id)); + if (validActive.length <= DEFAULT_CONTEXT_ITEM_LIMIT) { + return validActive; + } + + const removable = validActive.filter((id) => !manualContextIds.includes(id)); + if (removable.length === 0) { + return validActive.slice(0, DEFAULT_CONTEXT_ITEM_LIMIT); + } + + const removeSet = new Set(); + for (const id of removable) { + if (validActive.length - removeSet.size <= DEFAULT_CONTEXT_ITEM_LIMIT) { + break; + } + removeSet.add(id); + } + + return validActive.filter((id) => !removeSet.has(id)); + }); + }, [contextCatalog, enabled, manualContextIds]); + + const activeContextSet = useMemo(() => new Set(activeContextIds), [activeContextIds]); + + const activeContextTokenUsage = useMemo( + () => + orderedActiveContextItems.reduce( + (sum, item) => sum + resolveContextTokenEstimate(item, contextBodyById), + 0, + ), + [contextBodyById, orderedActiveContextItems], + ); + + useEffect(() => { + if (!enabled) { + return; + } + + if (activeContextTokenUsage <= DEFAULT_CONTEXT_TOKEN_LIMIT) { + return; + } + + const removable = orderedActiveContextItems + .filter((item) => !manualContextIds.includes(item.id)) + .sort((left, right) => { + if (left.source === "search" && right.source !== "search") { + return -1; + } + if (left.source !== "search" && right.source === "search") { + return 1; + } + return ( + resolveContextTokenEstimate(right, contextBodyById) - + resolveContextTokenEstimate(left, contextBodyById) + ); + }); + + if (removable.length === 0) { + return; + } + + let currentTokens = activeContextTokenUsage; + const removeSet = new Set(); + + for (const item of removable) { + if (currentTokens <= DEFAULT_CONTEXT_TOKEN_LIMIT) { + break; + } + removeSet.add(item.id); + currentTokens -= resolveContextTokenEstimate(item, contextBodyById); + } + + if (removeSet.size > 0) { + setActiveContextIds((previous) => previous.filter((id) => !removeSet.has(id))); + } + }, [ + activeContextTokenUsage, + contextBodyById, + enabled, + manualContextIds, + orderedActiveContextItems, + ]); + + const loadContextBody = useCallback( + async (item: ContextCatalogItem): Promise => { + const cached = contextBodyByIdRef.current[item.id]; + if (cached) { + return cached; + } + + if (item.source === "search") { + const summary = normalizeText(item.bodyText || item.previewText || item.name); + setContextBodyById((previous) => + previous[item.id] === summary ? previous : { ...previous, [item.id]: summary }, + ); + return summary; + } + + const inflight = contextBodyPromiseRef.current[item.id]; + if (inflight) { + return inflight; + } + + const promise = (async () => { + try { + let nextBody = ""; + if (item.source === "material") { + const materialId = item.id.replace(/^material:/, ""); + nextBody = await getMaterialContent(materialId); + } else { + const contentId = item.id.replace(/^content:/, ""); + const detail = await getContent(contentId); + nextBody = detail?.body || ""; + } + + const normalizedBody = truncateContextBody( + nextBody || item.previewText || item.name, + item.source, + ); + setContextBodyById((previous) => + previous[item.id] === normalizedBody + ? previous + : { ...previous, [item.id]: normalizedBody }, + ); + return normalizedBody; + } catch (error) { + console.warn("[useThemeContextWorkspace] 加载上下文正文失败:", error); + const fallbackBody = truncateContextBody(item.previewText || item.name, item.source); + setContextBodyById((previous) => + previous[item.id] === fallbackBody + ? previous + : { ...previous, [item.id]: fallbackBody }, + ); + return fallbackBody; + } finally { + delete contextBodyPromiseRef.current[item.id]; + } + })(); + + contextBodyPromiseRef.current[item.id] = promise; + return promise; + }, + [getMaterialContent], + ); + + useEffect(() => { + if (!enabled) { + return; + } + + orderedActiveContextItems.forEach((item) => { + if (item.source === "search") { + return; + } + if (contextBodyByIdRef.current[item.id]) { + return; + } + void loadContextBody(item); + }); + }, [enabled, loadContextBody, orderedActiveContextItems]); + + const sidebarContextItems = useMemo( + () => + contextCatalog.map((item) => ({ + id: item.id, + name: item.name, + source: item.source, + searchMode: item.searchMode, + query: item.query, + previewText: item.previewText, + citations: item.citations, + createdAt: item.createdAt, + active: activeContextSet.has(item.id), + })), + [activeContextSet, contextCatalog], + ); + + const activityLogs = useMemo(() => { + if (!enabled) { + return []; + } + + const logs: SidebarActivityLog[] = []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message.toolCalls || message.toolCalls.length === 0) { + continue; + } + + for (const toolCall of message.toolCalls) { + const status = + toolCall.status === "running" || + toolCall.status === "completed" || + toolCall.status === "failed" + ? toolCall.status + : "failed"; + const logId = `${message.id}-${toolCall.id}`; + const existingSnapshot = contextSnapshotByToolCallRef.current[logId]; + if (!existingSnapshot || (existingSnapshot.length === 0 && activeContextIds.length > 0)) { + contextSnapshotByToolCallRef.current[logId] = [...activeContextIds]; + } + + const inputSummary = toolCall.arguments + ? truncateText(toolCall.arguments, 80) + : undefined; + const outputSummary = toolCall.result?.error + ? truncateText(toolCall.result.error, 80) + : toolCall.result?.output + ? truncateText(toolCall.result.output, 80) + : undefined; + const artifactPaths = extractToolCallArtifactPaths( + toolCall.arguments, + toolCall.result?.output, + ); + + logs.push({ + id: logId, + name: toolCall.name, + status, + timeLabel: formatLogTimeLabel(message.timestamp), + durationLabel: formatDurationLabel(toolCall.startTime, toolCall.endTime), + applyTarget: resolveApplyTarget(toolCall.name), + contextIds: contextSnapshotByToolCallRef.current[logId], + inputSummary, + outputSummary, + messageId: message.id, + artifactPaths: artifactPaths.length > 0 ? artifactPaths : undefined, + }); + if (logs.length >= 20) { + return logs; + } + } + } + return logs; + }, [activeContextIds, enabled, messages]); + + const activeContextPrompt = useMemo( + () => buildActiveContextPromptText(orderedActiveContextItems, contextBodyById), + [contextBodyById, orderedActiveContextItems], + ); + + const prepareActiveContextPrompt = useCallback(async () => { + if (!enabled) { + return ""; + } + + if (orderedActiveContextItems.length === 0) { + return ""; + } + + const resolvedBodies = await Promise.all( + orderedActiveContextItems.map(async (item) => ({ + id: item.id, + body: await loadContextBody(item), + })), + ); + + const mergedContextBodies = { + ...contextBodyByIdRef.current, + }; + resolvedBodies.forEach(({ id, body }) => { + mergedContextBodies[id] = body; + }); + + return buildActiveContextPromptText(orderedActiveContextItems, mergedContextBodies); + }, [enabled, loadContextBody, orderedActiveContextItems]); + + const setContextSearchQuery = useCallback((value: string) => { + setContextSearchError(null); + setContextSearchQueryState(value); + }, []); + + const setContextSearchMode = useCallback((value: ThemeContextSearchMode) => { + setContextSearchError(null); + setContextSearchModeState(value); + }, []); + + const contextSearchBlockedReason = useMemo(() => { + if (!enabled) { + return null; + } + if (!contextProjectId) { + return "请先选择项目后再添加上下文"; + } + if (!providerType?.trim() || !model?.trim()) { + return "请先选择可用模型后再搜索"; + } + return null; + }, [contextProjectId, enabled, model, providerType]); + + const submitContextSearch = useCallback(async () => { + if (!enabled) { + return; + } + + const trimmedQuery = contextSearchQuery.trim(); + if (!trimmedQuery || contextSearchLoading) { + return; + } + + if (!contextProjectId) { + setContextSearchError("请先选择项目后再添加上下文"); + return; + } + + if (!providerType?.trim() || !model?.trim()) { + setContextSearchError("当前未选择可用模型,无法执行联网搜索"); + return; + } + + setContextSearchLoading(true); + setContextSearchError(null); + + try { + const result = await searchThemeContextWithWebSearch({ + workspaceId: contextProjectId, + projectId: contextProjectId, + providerType, + model, + query: trimmedQuery, + mode: contextSearchMode, + }); + + const contextId = buildGeneratedContextId(contextSearchMode, trimmedQuery); + const nextContext: GeneratedSearchContextItem = { + id: contextId, + name: result.title, + source: "search", + searchMode: contextSearchMode, + query: trimmedQuery, + summary: result.summary, + citations: result.citations, + rawResponse: result.rawResponse, + createdAt: Date.now(), + }; + + setGeneratedSearchContexts((previous) => [ + nextContext, + ...previous.filter((item) => item.id !== contextId), + ]); + setContextBodyById((previous) => ({ + ...previous, + [contextId]: normalizeText(result.summary), + })); + setContextSearchQueryState(""); + setActiveContextIds((previous) => + previous.includes(contextId) ? previous : [contextId, ...previous], + ); + setManualContextIds((previous) => + previous.includes(contextId) ? previous : [...previous, contextId], + ); + } catch (error) { + setContextSearchError( + error instanceof Error ? error.message : String(error || "上下文搜索失败"), + ); + } finally { + setContextSearchLoading(false); + } + }, [ + contextProjectId, + contextSearchLoading, + contextSearchMode, + contextSearchQuery, + enabled, + model, + providerType, + ]); + + const addTextContext = useCallback( + async (payload: { content: string; name?: string }) => { + if (!enabled) { + throw new Error("当前主题不支持添加上下文"); + } + if (!contextProjectId) { + throw new Error("请先选择项目后再添加上下文"); + } + + const normalizedContent = payload.content.trim(); + if (!normalizedContent) { + throw new Error("请输入文本内容"); + } + + const firstLine = normalizedContent.split(/\r?\n/).find((line) => line.trim().length > 0) || ""; + const material = await uploadMaterial({ + projectId: contextProjectId, + name: resolveContextTitle(payload.name || firstLine, "文本上下文"), + type: "text", + content: normalizedContent, + tags: ["上下文", "文本"], + }); + + const contextId = `material:${material.id}`; + setContextBodyById((previous) => ({ + ...previous, + [contextId]: truncateContextBody(normalizedContent, "material"), + })); + setActiveContextIds((previous) => + previous.includes(contextId) ? previous : [contextId, ...previous], + ); + }, + [contextProjectId, enabled, uploadMaterial], + ); + + const addLinkContext = useCallback( + async (payload: { url: string; name?: string }) => { + if (!enabled) { + throw new Error("当前主题不支持添加上下文"); + } + if (!contextProjectId) { + throw new Error("请先选择项目后再添加上下文"); + } + + const normalizedUrl = payload.url.trim(); + if (!normalizedUrl) { + throw new Error("请输入网站链接"); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(normalizedUrl); + } catch { + throw new Error("链接格式不正确"); + } + + const fallbackName = parsedUrl.hostname || "网站链接"; + const material = await uploadMaterial({ + projectId: contextProjectId, + name: resolveContextTitle(payload.name || fallbackName, "网站链接"), + type: "link", + content: parsedUrl.toString(), + tags: ["上下文", "链接"], + }); + + const contextId = `material:${material.id}`; + setContextBodyById((previous) => ({ + ...previous, + [contextId]: truncateContextBody(parsedUrl.toString(), "material"), + })); + setActiveContextIds((previous) => + previous.includes(contextId) ? previous : [contextId, ...previous], + ); + }, + [contextProjectId, enabled, uploadMaterial], + ); + + const addFileContext = useCallback( + async (payload: { path: string; name?: string }) => { + if (!enabled) { + throw new Error("当前主题不支持添加上下文"); + } + if (!contextProjectId) { + throw new Error("请先选择项目后再添加上下文"); + } + + const normalizedPath = payload.path.trim(); + if (!normalizedPath) { + throw new Error("文件路径无效"); + } + + const material = await uploadMaterial({ + projectId: contextProjectId, + name: resolveContextTitle(payload.name || resolvePathFileName(normalizedPath), "上下文文件"), + type: "document", + filePath: normalizedPath, + tags: ["上下文", "文件"], + }); + + const contextId = `material:${material.id}`; + setActiveContextIds((previous) => + previous.includes(contextId) ? previous : [contextId, ...previous], + ); + }, + [contextProjectId, enabled, uploadMaterial], + ); + + const toggleContextActive = useCallback((contextId: string) => { + setActiveContextIds((previous) => { + const exists = previous.includes(contextId); + if (exists) { + setManualContextIds((manualPrevious) => + manualPrevious.filter((id) => id !== contextId), + ); + return previous.filter((id) => id !== contextId); + } + setManualContextIds((manualPrevious) => + manualPrevious.includes(contextId) + ? manualPrevious + : [...manualPrevious, contextId], + ); + return [...previous, contextId]; + }); + }, []); + + const getContextDetail = useCallback( + (contextId: string) => { + return contextCatalogById.get(contextId) ?? null; + }, + [contextCatalogById], + ); + + return { + enabled, + contextSearchQuery, + setContextSearchQuery, + contextSearchMode, + setContextSearchMode, + contextSearchLoading, + contextSearchError, + contextSearchBlockedReason, + submitContextSearch, + addTextContext, + addLinkContext, + addFileContext, + sidebarContextItems, + toggleContextActive, + getContextDetail, + contextBudget: { + activeCount: activeContextSet.size, + activeCountLimit: DEFAULT_CONTEXT_ITEM_LIMIT, + estimatedTokens: activeContextTokenUsage, + tokenLimit: DEFAULT_CONTEXT_TOKEN_LIMIT, + }, + activityLogs, + activeContextPrompt, + prepareActiveContextPrompt, + }; +} diff --git a/src/components/agent/chat/hooks/useTopicBranchBoard.test.tsx b/src/components/agent/chat/hooks/useTopicBranchBoard.test.tsx new file mode 100644 index 000000000..27382dfff --- /dev/null +++ b/src/components/agent/chat/hooks/useTopicBranchBoard.test.tsx @@ -0,0 +1,189 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useTopicBranchBoard } from "./useTopicBranchBoard"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + sessionStorage.clear(); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + sessionStorage.clear(); +}); + +interface ProbeProps { + enabled: boolean; + projectId?: string; + currentTopicId: string | null; + onSnapshot: (value: ReturnType) => void; + externalStatusMap?: Record; + onStatusMapChange?: ( + next: Record, + ) => void; +} + +function Probe({ + enabled, + projectId, + currentTopicId, + onSnapshot, + externalStatusMap, + onStatusMapChange, +}: ProbeProps) { + const result = useTopicBranchBoard({ + enabled, + projectId, + currentTopicId, + topics: [ + { id: "topic-a", title: "话题 A", messagesCount: 3 }, + { id: "topic-b", title: "话题 B", messagesCount: 0 }, + ], + externalStatusMap, + onStatusMapChange, + }); + onSnapshot(result); + return null; +} + +describe("useTopicBranchBoard", () => { + it("当前话题应自动为进行中", async () => { + let snapshot: ReturnType | null = null; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + + await act(async () => { + root.render( + { + snapshot = value; + }} + />, + ); + }); + + const current = snapshot?.branchItems.find((item) => item.id === "topic-a"); + expect(current?.status).toBe("in_progress"); + }); + + it("应允许手动设置分支状态", async () => { + let snapshot: ReturnType | null = null; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + + await act(async () => { + root.render( + { + snapshot = value; + }} + />, + ); + }); + + await act(async () => { + snapshot?.setTopicStatus("topic-b", "merged"); + }); + + const merged = snapshot?.branchItems.find((item) => item.id === "topic-b"); + expect(merged?.status).toBe("merged"); + }); + + it("应忽略 sessionStorage 中非法状态值", async () => { + sessionStorage.setItem( + "agent_topic_branch_status_project-3", + JSON.stringify({ + "topic-a": "unknown_status", + "topic-b": "merged", + }), + ); + + let snapshot: ReturnType | null = null; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + + await act(async () => { + root.render( + { + snapshot = value; + }} + />, + ); + }); + + const current = snapshot?.branchItems.find((item) => item.id === "topic-a"); + const merged = snapshot?.branchItems.find((item) => item.id === "topic-b"); + expect(current?.status).toBe("in_progress"); + expect(merged?.status).toBe("merged"); + }); + + it("外部托管模式应回调状态变更", async () => { + let snapshot: ReturnType | null = null; + let controlledMap: Record = { + "topic-a": "in_progress", + "topic-b": "candidate", + }; + const handleStatusMapChange = ( + next: Record, + ) => { + controlledMap = next; + }; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + + await act(async () => { + root.render( + { + snapshot = value; + }} + />, + ); + }); + + await act(async () => { + snapshot?.setTopicStatus("topic-b", "merged"); + }); + + expect(controlledMap["topic-b"]).toBe("merged"); + }); +}); diff --git a/src/components/agent/chat/hooks/useTopicBranchBoard.ts b/src/components/agent/chat/hooks/useTopicBranchBoard.ts new file mode 100644 index 000000000..f8ce61f2b --- /dev/null +++ b/src/components/agent/chat/hooks/useTopicBranchBoard.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; + +const TOPIC_BRANCH_STATUS_KEY_PREFIX = "agent_topic_branch_status_"; + +export type TopicBranchStatus = + | "in_progress" + | "pending" + | "merged" + | "candidate"; + +export interface TopicBranchItem { + id: string; + title: string; + status: TopicBranchStatus; + isCurrent: boolean; +} + +interface TopicLike { + id: string; + title: string; + messagesCount?: number; +} + +interface UseTopicBranchBoardOptions { + enabled: boolean; + projectId?: string; + currentTopicId: string | null; + topics: TopicLike[]; + externalStatusMap?: Record; + onStatusMapChange?: (next: Record) => void; +} + +function buildStorageKey(projectId: string): string { + return `${TOPIC_BRANCH_STATUS_KEY_PREFIX}${projectId}`; +} + +function loadBranchStatusMap(storageKey: string): Record { + try { + const raw = sessionStorage.getItem(storageKey); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== "object") { + return {}; + } + const next: Record = {}; + for (const [topicId, status] of Object.entries(parsed)) { + if ( + status === "in_progress" || + status === "pending" || + status === "merged" || + status === "candidate" + ) { + next[topicId] = status; + } + } + return next; + } catch { + return {}; + } +} + +function saveBranchStatusMap( + storageKey: string, + value: Record, +): void { + try { + sessionStorage.setItem(storageKey, JSON.stringify(value)); + } catch { + // ignore + } +} + +function resolveDefaultStatus( + topic: TopicLike, + isCurrent: boolean, +): TopicBranchStatus { + if (isCurrent) { + return "in_progress"; + } + if ((topic.messagesCount ?? 0) >= 2) { + return "pending"; + } + return "candidate"; +} + +export function useTopicBranchBoard({ + enabled, + projectId, + currentTopicId, + topics, + externalStatusMap, + onStatusMapChange, +}: UseTopicBranchBoardOptions) { + const normalizedProjectId = normalizeProjectId(projectId); + const storageKey = normalizedProjectId ? buildStorageKey(normalizedProjectId) : null; + const [innerStatusMap, setInnerStatusMap] = useState>({}); + const useExternalState = !!(externalStatusMap && onStatusMapChange); + const statusMap = useExternalState ? externalStatusMap : innerStatusMap; + + const updateStatusMap = useCallback( + ( + updater: + | Record + | ((previous: Record) => Record), + ) => { + if (useExternalState) { + const previous = externalStatusMap || {}; + const next = typeof updater === "function" ? updater(previous) : updater; + onStatusMapChange?.(next); + return; + } + + setInnerStatusMap((previous) => + typeof updater === "function" ? updater(previous) : updater, + ); + }, + [externalStatusMap, onStatusMapChange, useExternalState], + ); + + useEffect(() => { + if (useExternalState) { + return; + } + if (!enabled || !storageKey) { + setInnerStatusMap({}); + return; + } + setInnerStatusMap(loadBranchStatusMap(storageKey)); + }, [enabled, storageKey, useExternalState]); + + useEffect(() => { + if (useExternalState) { + return; + } + if (!enabled || !storageKey) { + return; + } + saveBranchStatusMap(storageKey, statusMap); + }, [enabled, statusMap, storageKey, useExternalState]); + + useEffect(() => { + if (!enabled) { + return; + } + + updateStatusMap((previous) => { + const next: Record = {}; + for (const topic of topics) { + const isCurrent = topic.id === currentTopicId; + if (isCurrent) { + next[topic.id] = "in_progress"; + continue; + } + next[topic.id] = + previous[topic.id] || resolveDefaultStatus(topic, false); + } + + const previousKeys = Object.keys(previous); + const nextKeys = Object.keys(next); + if (previousKeys.length === nextKeys.length) { + const unchanged = nextKeys.every((key) => previous[key] === next[key]); + if (unchanged) { + return previous; + } + } + + return next; + }); + }, [currentTopicId, enabled, topics, updateStatusMap]); + + const setTopicStatus = useCallback( + (topicId: string, status: TopicBranchStatus) => { + if (!enabled) { + return; + } + updateStatusMap((previous) => ({ + ...previous, + [topicId]: status, + })); + }, + [enabled, updateStatusMap], + ); + + const branchItems = useMemo( + () => + topics.map((topic) => { + const isCurrent = topic.id === currentTopicId; + return { + id: topic.id, + title: topic.title, + status: + statusMap[topic.id] || resolveDefaultStatus(topic, isCurrent), + isCurrent, + }; + }), + [currentTopicId, statusMap, topics], + ); + + return { + branchItems, + setTopicStatus, + }; +} diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 654771ad4..5b5b3044e 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -4,10 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { mockUseAgentChatUnified, + mockUseThemeContextWorkspace, + mockUseTopicBranchBoard, mockGetProject, mockGetDefaultProject, mockGetOrCreateDefaultProject, mockGetContent, + mockGetThemeWorkbenchDocumentState, mockUpdateContent, mockGetProjectMemory, mockToast, @@ -17,12 +20,20 @@ const { mockGenerateContentCreationPrompt, mockIsContentCreationTheme, mockEmptyState, + mockInputbar, + mockMessageList, + mockExecutionRunGetThemeWorkbenchState, + mockExecutionRunGet, + mockSkillExecutionGetDetail, } = vi.hoisted(() => ({ mockUseAgentChatUnified: vi.fn(), + mockUseThemeContextWorkspace: vi.fn(), + mockUseTopicBranchBoard: vi.fn(), mockGetProject: vi.fn(), mockGetDefaultProject: vi.fn(), mockGetOrCreateDefaultProject: vi.fn(), mockGetContent: vi.fn(), + mockGetThemeWorkbenchDocumentState: vi.fn(), mockUpdateContent: vi.fn(), mockGetProjectMemory: vi.fn(), mockToast: { @@ -39,6 +50,15 @@ const { mockEmptyState: vi.fn((props?: { input?: string }) => (
{props?.input || ""}
)), + mockInputbar: vi.fn((_props?: Record) => ( +
+ )), + mockMessageList: vi.fn((_props?: Record) => ( +
+ )), + mockExecutionRunGetThemeWorkbenchState: vi.fn(), + mockExecutionRunGet: vi.fn(), + mockSkillExecutionGetDetail: vi.fn(), })); vi.mock("sonner", () => ({ @@ -47,11 +67,13 @@ vi.mock("sonner", () => ({ vi.mock("./hooks", () => ({ useAgentChatUnified: mockUseAgentChatUnified, + useThemeContextWorkspace: mockUseThemeContextWorkspace, + useTopicBranchBoard: mockUseTopicBranchBoard, })); vi.mock("./hooks/useSessionFiles", () => ({ useSessionFiles: () => ({ - saveFile: vi.fn(), + saveFile: vi.fn(async () => undefined), files: [], readFile: vi.fn(async () => null), meta: null, @@ -75,8 +97,23 @@ vi.mock("@/components/content-creator/hooks/useWorkflow", () => ({ })); vi.mock("@/components/content-creator/core/LayoutTransition/LayoutTransition", () => ({ - LayoutTransition: ({ chatContent }: { chatContent: ReactNode }) => ( -
{chatContent}
+ LayoutTransition: ({ + mode, + chatContent, + canvasContent, + }: { + mode: string; + chatContent: ReactNode; + canvasContent: ReactNode; + }) => ( +
+ + +
), })); @@ -131,12 +168,62 @@ vi.mock("./components/ChatSidebar", () => ({ ), })); +vi.mock("./components/ThemeWorkbenchSidebar", () => ({ + ThemeWorkbenchSidebar: ({ + onSwitchTopic, + onSetBranchStatus, + workflowSteps, + activityLogs, + }: { + onSwitchTopic?: (topicId: string) => Promise | void; + onSetBranchStatus?: ( + topicId: string, + status: "in_progress" | "pending" | "merged" | "candidate", + ) => void; + workflowSteps?: Array<{ title: string; status: string }>; + activityLogs?: Array<{ runId?: string; executionId?: string; id: string }>; + }) => ( +
`${step.title}:${step.status}`) + .join("|")} + data-activity-runs={(activityLogs || []) + .map((log) => log.runId || "-") + .join("|")} + data-activity-executions={(activityLogs || []) + .map((log) => log.executionId || "-") + .join("|")} + > + + +
+ ), +})); + + vi.mock("./components/MessageList", () => ({ - MessageList: () =>
, + MessageList: (props: Record) => mockMessageList(props), })); vi.mock("./components/Inputbar", () => ({ - Inputbar: () =>
, + Inputbar: (props: Record) => mockInputbar(props), })); vi.mock("./components/EmptyState", () => ({ @@ -186,9 +273,9 @@ vi.mock("@/components/content-creator/canvas/canvasUtils", () => ({ })); vi.mock("@/components/content-creator/canvas/document", () => ({ - createInitialDocumentState: vi.fn(() => ({ + createInitialDocumentState: vi.fn((content = "") => ({ type: "document", - content: "", + content, versions: [], currentVersionId: "", isEditing: true, @@ -208,6 +295,7 @@ vi.mock("@/lib/api/project", () => ({ getDefaultProject: mockGetDefaultProject, getOrCreateDefaultProject: mockGetOrCreateDefaultProject, getContent: mockGetContent, + getThemeWorkbenchDocumentState: mockGetThemeWorkbenchDocumentState, updateContent: mockUpdateContent, })); @@ -215,11 +303,23 @@ vi.mock("@/lib/api/memory", () => ({ getProjectMemory: mockGetProjectMemory, })); +vi.mock("@/lib/api/executionRun", () => ({ + executionRunGet: mockExecutionRunGet, + executionRunGetThemeWorkbenchState: mockExecutionRunGetThemeWorkbenchState, +})); + +vi.mock("@/lib/api/skill-execution", () => ({ + skillExecutionApi: { + getSkillDetail: mockSkillExecutionGetDetail, + }, +})); + import { AgentChatPage } from "./index"; interface MountedHarness { container: HTMLDivElement; root: Root; + rerender: (props?: Partial>) => void; } const mountedRoots: MountedHarness[] = []; @@ -243,19 +343,79 @@ function createProject(id: string, archived = false) { }; } -function renderPage( - props: Partial> = {}, -): HTMLDivElement { +function mountPage( + initialProps: Partial> = {}, +): MountedHarness { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + let currentProps = initialProps; + + const render = () => { + root.render(); + }; act(() => { - root.render(); + render(); }); - mountedRoots.push({ container, root }); - return container; + const harness: MountedHarness = { + container, + root, + rerender: (props = {}) => { + currentProps = { ...currentProps, ...props }; + act(() => { + render(); + }); + }, + }; + + mountedRoots.push(harness); + return harness; +} + +function renderPage( + props: Partial> = {}, +): HTMLDivElement { + return mountPage(props).container; +} + +function createMockThemeContextWorkspaceState( + overrides: Partial< + ReturnType + > = {}, +) { + const merged = { + enabled: false, + contextSearchQuery: "", + setContextSearchQuery: vi.fn(), + contextSearchMode: "web" as const, + setContextSearchMode: vi.fn(), + contextSearchLoading: false, + contextSearchError: null, + contextSearchBlockedReason: null, + submitContextSearch: vi.fn(), + sidebarContextItems: [], + toggleContextActive: vi.fn(), + contextBudget: { + activeCount: 0, + activeCountLimit: 12, + estimatedTokens: 0, + tokenLimit: 32000, + }, + activityLogs: [], + activeContextPrompt: "", + prepareActiveContextPrompt: vi.fn().mockResolvedValue(""), + ...overrides, + }; + + if (!("prepareActiveContextPrompt" in overrides)) { + merged.prepareActiveContextPrompt = vi.fn().mockResolvedValue( + merged.activeContextPrompt || "", + ); + } + + return merged; } async function flushEffects(times = 6) { @@ -311,13 +471,44 @@ beforeEach(() => { mockGetDefaultProject.mockResolvedValue(null); mockGetOrCreateDefaultProject.mockResolvedValue(null); mockGetContent.mockResolvedValue(null); + mockGetThemeWorkbenchDocumentState.mockResolvedValue(null); mockUpdateContent.mockResolvedValue(undefined); mockGetProjectMemory.mockResolvedValue(null); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "idle", + queue_items: [], + latest_terminal: null, + updated_at: "2026-03-06T00:00:00.000Z", + }); + mockExecutionRunGet.mockResolvedValue(null); + mockSkillExecutionGetDetail.mockResolvedValue({ + name: "social_post_with_cover", + display_name: "社媒主稿与封面", + description: "生成社媒内容", + execution_mode: "prompt", + has_workflow: false, + workflow_steps: [], + }); mockGenerateContentCreationPrompt.mockReturnValue("mock-system-prompt"); mockIsContentCreationTheme.mockReturnValue(false); mockEmptyState.mockImplementation((props?: { input?: string }) => (
{props?.input || ""}
)); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState(), + ); + mockInputbar.mockClear(); + mockUseTopicBranchBoard.mockReturnValue({ + branchItems: [ + { + id: "topic-a", + title: "话题 A", + status: "in_progress", + isCurrent: true, + }, + ], + setTopicStatus: vi.fn(), + }); sharedSwitchTopicMock = vi.fn(async () => undefined); sharedSendMessageMock = vi.fn(async () => undefined); @@ -452,6 +643,25 @@ describe("AgentChatPage 话题切换项目恢复", () => { "project-manual", ); }); + + it("收到首页新会话请求时应先丢弃内部项目上下文", async () => { + const mounted = mountPage(); + await flushEffects(); + + clickButton(mounted.container, "set-project"); + await flushEffects(); + expect(observedWorkspaceIds[observedWorkspaceIds.length - 1]).toBe( + "project-manual", + ); + + mounted.rerender({ newChatAt: 2233445566 }); + + expect(observedWorkspaceIds[observedWorkspaceIds.length - 1]).toBe(""); + + await flushEffects(); + expect(observedWorkspaceIds[observedWorkspaceIds.length - 1]).toBe(""); + }); + }); describe("AgentChatPage 侧栏显示控制", () => { @@ -566,9 +776,1132 @@ describe("AgentChatPage 自动引导", () => { false, false, undefined, - expect.any(String), + "mock-model", + undefined, + undefined, ); expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); }); + + it("主题上下文启用时应把生效上下文前置到发送内容", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + activeContextPrompt: "[生效上下文]\n1. [素材] 品牌手册", + }), + ); + + const initialUserPrompt = "请写一条小红书文案"; + renderPage({ + projectId: "project-social-context", + contentId: "content-social-context", + theme: "social-media", + lockTheme: true, + initialUserPrompt, + onInitialUserPromptConsumed: vi.fn(), + }); + await flushEffects(12); + + expect(sharedSendMessageMock).toHaveBeenCalledWith( + `/social_post_with_cover [生效上下文]\n1. [素材] 品牌手册\n\n${initialUserPrompt}`, + [], + false, + false, + false, + undefined, + "mock-model", + undefined, + undefined, + ); + }); + + it("存在 initialUserPrompt 时应使用当前选中模型发送", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + const selectedModel = "gemini-2.5-pro"; + const onInitialUserPromptConsumed = vi.fn(); + const initialUserPrompt = "请生成面向 CTO 的社媒提纲"; + + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "gemini", + setProviderType: vi.fn(), + model: selectedModel, + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [], + isSending: false, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [ + { + id: "topic-a", + title: "话题 A", + updatedAt: Date.now(), + }, + ], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + }; + }, + ); + + renderPage({ + projectId: "project-social-selected-model", + contentId: "content-social-selected-model", + theme: "social-media", + lockTheme: true, + initialUserPrompt, + onInitialUserPromptConsumed, + }); + await flushEffects(12); + + expect(sharedSendMessageMock).toHaveBeenCalledWith( + initialUserPrompt, + [], + false, + false, + false, + undefined, + selectedModel, + undefined, + undefined, + ); + expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); + }); + + it("主题工作台启用时应优先展示画布,不再回退到旧聊天预留页", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + const container = renderPage({ + projectId: "project-social-canvas-first", + contentId: "content-social-canvas-first", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(10); + + const layout = container.querySelector('[data-testid="layout-transition"]'); + expect(layout?.getAttribute("data-mode")).toBe("canvas"); + expect(container.querySelector('[data-testid="canvas-loading-state"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="layout-chat"]')?.hasAttribute("hidden")).toBe(true); + }); + + it("主题工作台打开已有文稿时首帧应直接显示画布,避免旧对话闪现", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockGetContent.mockResolvedValue({ + id: "content-social-canvas-sync", + body: "# 已有主稿\n\n这里是正文。", + metadata: {}, + }); + + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [{ id: "msg-restored", role: "user", content: "历史对话" }], + isSending: false, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [ + { + id: "topic-a", + title: "话题 A", + updatedAt: Date.now(), + }, + ], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + }; + }, + ); + + const container = renderPage({ + projectId: "project-social-canvas-sync", + contentId: "content-social-canvas-sync", + theme: "social-media", + lockTheme: true, + }); + + const layout = container.querySelector('[data-testid="layout-transition"]'); + expect(layout?.getAttribute("data-mode")).toBe("canvas"); + expect(container.querySelector('[data-testid="layout-chat"]')?.hasAttribute("hidden")).toBe(true); + expect(container.querySelector('[data-testid="canvas-loading-state"]')).not.toBeNull(); + expect(container.textContent).not.toContain("历史对话"); + + await flushEffects(10); + + expect(container.querySelector('[data-testid="canvas-factory"]')).not.toBeNull(); + }); + + it("主题工作台启用时应仅保留专用侧栏,不再渲染右侧旧操作面板", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + const container = renderPage({ + projectId: "project-social-layout", + contentId: "content-social-layout", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(10); + + expect(container.querySelector('[data-testid="theme-workbench-sidebar"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="theme-workbench-skills"]')).toBeNull(); + expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); + expect(container.querySelector('[data-testid="empty-state"]')).toBeNull(); + expect(container.querySelector('[data-testid="inputbar"]')).not.toBeNull(); + }); + + + + it("主题工作台在初始意图稍后注入时应自动发送首条创作请求", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + const onInitialUserPromptConsumed = vi.fn(); + + renderPage({ + projectId: "project-theme-delayed-intent", + contentId: "content-theme-delayed-intent", + theme: "social-media", + lockTheme: true, + initialUserPrompt: undefined, + onInitialUserPromptConsumed, + }); + await flushEffects(8); + + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + + const mounted = mountedRoots.at(-1); + expect(mounted).toBeTruthy(); + + act(() => { + mounted?.root.render( + , + ); + }); + await flushEffects(10); + + expect(sharedSendMessageMock).toHaveBeenCalledWith( + "/social_post_with_cover 请基于当前上下文直接开始生成首版社媒主稿。", + [], + false, + false, + false, + undefined, + expect.any(String), + undefined, + undefined, + ); + expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); + }); + + it("主题工作台空文稿不应再自动注入旧版提问引导词", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + renderPage({ + projectId: "project-theme-no-legacy-guide", + contentId: "content-theme-no-legacy-guide", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { input?: string } + | undefined; + expect(latestInputbarProps?.input || "").toBe(""); + }); + + it("主题工作台空闲时应把 success 终态版本标记为 merged", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockGetContent.mockResolvedValue({ + id: "content-theme-success", + body: "当前主稿", + metadata: {}, + }); + mockGetThemeWorkbenchDocumentState.mockResolvedValue({ + content_id: "content-theme-success", + current_version_id: "run-success", + version_count: 1, + versions: [ + { + id: "run-success", + created_at: Date.now(), + description: "版本 1", + status: "in_progress", + is_current: true, + }, + ], + }); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "idle", + queue_items: [], + latest_terminal: { + run_id: "run-success", + title: "执行主题工作台技能", + status: "success", + source: "skill", + source_ref: null, + started_at: "2026-03-06T01:00:00.000Z", + finished_at: "2026-03-06T01:00:10.000Z", + }, + updated_at: "2026-03-06T01:00:10.000Z", + }); + + renderPage({ + projectId: "project-theme-success", + contentId: "content-theme-success", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(16); + + const latestCall = mockUseTopicBranchBoard.mock.calls.at(-1)?.[0] as + | { externalStatusMap?: Record } + | undefined; + expect(latestCall?.externalStatusMap).toMatchObject({ + "run-success": "merged", + }); + }); + + it("主题工作台空闲时应把 error 终态版本标记为 candidate", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockGetContent.mockResolvedValue({ + id: "content-theme-error", + body: "当前主稿", + metadata: {}, + }); + mockGetThemeWorkbenchDocumentState.mockResolvedValue({ + content_id: "content-theme-error", + current_version_id: "run-error", + version_count: 1, + versions: [ + { + id: "run-error", + created_at: Date.now(), + description: "版本 1", + status: "in_progress", + is_current: true, + }, + ], + }); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "idle", + queue_items: [], + latest_terminal: { + run_id: "run-error", + title: "执行主题工作台技能", + status: "error", + source: "skill", + source_ref: null, + started_at: "2026-03-06T02:00:00.000Z", + finished_at: "2026-03-06T02:00:10.000Z", + }, + updated_at: "2026-03-06T02:00:10.000Z", + }); + + renderPage({ + projectId: "project-theme-error", + contentId: "content-theme-error", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(16); + + const latestCall = mockUseTopicBranchBoard.mock.calls.at(-1)?.[0] as + | { externalStatusMap?: Record } + | undefined; + expect(latestCall?.externalStatusMap).toMatchObject({ + "run-error": "candidate", + }); + }); + + it("主题工作台写入辅助产物时不应覆盖主稿正文", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockGetContent.mockResolvedValue({ + id: "content-theme-artifact-guard", + body: "旧内容", + metadata: {}, + }); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "auto_running", + current_gate_key: "write_mode", + queue_items: [ + { + run_id: "run-write-main", + title: "写作阶段", + gate_key: "write_mode", + status: "running", + source: "skill", + source_ref: null, + started_at: "2026-03-06T03:30:00.000Z", + }, + ], + latest_terminal: null, + updated_at: "2026-03-06T03:30:10.000Z", + }); + + renderPage({ + projectId: "project-theme-artifact-guard", + contentId: "content-theme-artifact-guard", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as + | { + onWriteFile?: (content: string, fileName: string) => void; + } + | undefined; + + expect(typeof latestMessageListProps?.onWriteFile).toBe("function"); + + act(() => { + latestMessageListProps?.onWriteFile?.( + "# 主稿标题\n\n这是主稿正文。", + "social-posts/demo-post.md", + ); + latestMessageListProps?.onWriteFile?.( + "{\"pipeline\":[\"topic_select\",\"write_mode\",\"publish_confirm\"]}", + "social-posts/demo-post.publish-pack.json", + ); + }); + await flushEffects(16); + + const bodyUpdateCalls = mockUpdateContent.mock.calls.filter((call) => { + const payload = call[1] as Record | undefined; + return Boolean(payload && "body" in payload); + }); + + expect(bodyUpdateCalls).toHaveLength(1); + expect(bodyUpdateCalls[0]?.[1]).toMatchObject({ + body: "# 主稿标题\n\n这是主稿正文。", + }); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + taskFiles?: Array<{ + id: string; + name: string; + type: string; + content?: string; + }>; + onTaskFileClick?: (file: { + id: string; + name: string; + type: string; + content?: string; + }) => void; + } + | undefined; + + expect(latestInputbarProps?.taskFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "social-posts/demo-post.md", + type: "document", + }), + ]), + ); + expect( + latestInputbarProps?.taskFiles?.some((file) => + file.name.endsWith(".publish-pack.json"), + ), + ).toBe(false); + }); + + it("主题工作台写入损坏的 markdown 产物时不应覆盖主稿正文", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockGetContent.mockResolvedValue({ + id: "content-theme-corrupted-markdown", + body: "旧内容", + metadata: {}, + }); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "auto_running", + current_gate_key: "write_mode", + queue_items: [ + { + run_id: "run-write-markdown", + title: "写作阶段", + gate_key: "write_mode", + status: "running", + source: "skill", + source_ref: null, + started_at: "2026-03-06T03:35:00.000Z", + }, + ], + latest_terminal: null, + updated_at: "2026-03-06T03:35:10.000Z", + }); + + renderPage({ + projectId: "project-theme-corrupted-markdown", + contentId: "content-theme-corrupted-markdown", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as + | { + onWriteFile?: (content: string, fileName: string) => void; + } + | undefined; + + act(() => { + latestMessageListProps?.onWriteFile?.( + JSON.stringify({ + article_path: "social-posts/demo-post.md", + pipeline: ["topic_select", "write_mode", "publish_confirm"], + }), + "social-posts/demo-post.md", + ); + }); + await flushEffects(16); + + const bodyUpdateCalls = mockUpdateContent.mock.calls.filter((call) => { + const payload = call[1] as Record | undefined; + return Boolean(payload && "body" in payload); + }); + + expect(bodyUpdateCalls).toHaveLength(0); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + taskFiles?: Array<{ + id: string; + name: string; + type: string; + }>; + } + | undefined; + + expect( + latestInputbarProps?.taskFiles?.some( + (file) => file.name === "social-posts/demo-post.md", + ), + ).toBe(false); + }); + + it("主题工作台在队列状态未就绪时写入主稿仍应创建可见版本", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockGetContent.mockResolvedValue({ + id: "content-theme-fallback-version", + body: "旧内容", + metadata: {}, + }); + mockGetThemeWorkbenchDocumentState.mockResolvedValue(null); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "idle", + queue_items: [], + latest_terminal: null, + updated_at: "2026-03-06T03:31:10.000Z", + }); + + renderPage({ + projectId: "project-theme-fallback-version", + contentId: "content-theme-fallback-version", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as + | { + onWriteFile?: (content: string, fileName: string) => void; + } + | undefined; + + expect(typeof latestMessageListProps?.onWriteFile).toBe("function"); + + act(() => { + latestMessageListProps?.onWriteFile?.( + "# 新主稿标题\n\n这是在队列未就绪时写入的主稿。", + "social-posts/local-fallback.md", + ); + }); + await flushEffects(16); + + const latestTopicBranchCall = mockUseTopicBranchBoard.mock.calls.at(-1)?.[0] as + | { topics?: Array<{ id: string }>; currentTopicId?: string | null } + | undefined; + expect(latestTopicBranchCall?.currentTopicId).toBe( + "artifact:social-posts/local-fallback.md", + ); + expect(latestTopicBranchCall?.topics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "artifact:social-posts/local-fallback.md" }), + ]), + ); + + const bodyUpdateCalls = mockUpdateContent.mock.calls.filter((call) => { + const payload = call[1] as Record | undefined; + return Boolean(payload && "body" in payload); + }); + expect(bodyUpdateCalls.length).toBeGreaterThan(0); + expect(bodyUpdateCalls.at(-1)?.[1]).toMatchObject({ + body: "# 新主稿标题\n\n这是在队列未就绪时写入的主稿。", + }); + }); + + it("主题工作台运行中应展示真实技能与工具步骤,而不是默认占位流程", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [ + { + id: "user-1", + role: "user", + content: "/social_post_with_cover 请生成一篇 AI 眼镜的社媒稿", + timestamp: new Date("2026-03-06T10:00:00.000Z"), + }, + { + id: "assistant-1", + role: "assistant", + content: "", + timestamp: new Date("2026-03-06T10:00:01.000Z"), + isThinking: true, + toolCalls: [ + { + id: "tool-write-1", + name: "write_file", + arguments: JSON.stringify({ path: "social-posts/final.md" }), + status: "completed", + startTime: new Date("2026-03-06T10:00:01.500Z"), + endTime: new Date("2026-03-06T10:00:02.000Z"), + }, + { + id: "tool-cover-1", + name: "social_generate_cover_image", + arguments: JSON.stringify({ size: "1024x1024" }), + status: "running", + startTime: new Date("2026-03-06T10:00:02.000Z"), + }, + ], + }, + ], + isSending: true, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + renderPage({ + projectId: "project-theme-real-steps", + contentId: "content-theme-real-steps", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + workflowSteps?: Array<{ title: string; status: string }>; + } + | undefined; + const workflowSteps = latestInputbarProps?.workflowSteps || []; + + expect(workflowSteps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "生成社媒主稿", status: "completed" }), + expect.objectContaining({ title: "写入 social-posts/final.md", status: "completed" }), + expect.objectContaining({ title: "生成封面图(1024x1024)", status: "active" }), + ]), + ); + expect(workflowSteps.some((step) => step.title === "平台适配")).toBe(false); + }); + + it("主题工作台封面工具失败时不应将主稿步骤误判为异常", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [ + { + id: "user-err-1", + role: "user", + content: "/social_post_with_cover 请生成一篇 AI 眼镜的社媒稿", + timestamp: new Date("2026-03-06T10:10:00.000Z"), + }, + { + id: "assistant-err-1", + role: "assistant", + content: "", + timestamp: new Date("2026-03-06T10:10:01.000Z"), + isThinking: true, + toolCalls: [ + { + id: "tool-write-ok", + name: "write_file", + arguments: JSON.stringify({ path: "social-posts/final.md" }), + status: "completed", + startTime: new Date("2026-03-06T10:10:01.500Z"), + endTime: new Date("2026-03-06T10:10:02.000Z"), + }, + { + id: "tool-cover-failed", + name: "social_generate_cover_image", + arguments: JSON.stringify({ size: "1024x1024" }), + status: "failed", + startTime: new Date("2026-03-06T10:10:02.000Z"), + endTime: new Date("2026-03-06T10:10:03.000Z"), + }, + ], + }, + ], + isSending: true, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + renderPage({ + projectId: "project-theme-step-status", + contentId: "content-theme-step-status", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + workflowSteps?: Array<{ title: string; status: string }>; + } + | undefined; + const workflowSteps = latestInputbarProps?.workflowSteps || []; + + expect(workflowSteps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "生成社媒主稿", status: "completed" }), + expect.objectContaining({ title: "生成封面图(1024x1024)", status: "error" }), + ]), + ); + }); + + it("主题工作台应将搜索与浏览工具映射为业务化标题", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [ + { + id: "user-2", + role: "user", + content: "/social_post_with_cover 请整理 Rokid Glasses 的亮点", + timestamp: new Date("2026-03-06T11:00:00.000Z"), + }, + { + id: "assistant-2", + role: "assistant", + content: "", + timestamp: new Date("2026-03-06T11:00:01.000Z"), + isThinking: true, + toolCalls: [ + { + id: "tool-search-1", + name: "search_query", + arguments: JSON.stringify({ q: "Rokid Glasses 最新功能" }), + status: "completed", + startTime: new Date("2026-03-06T11:00:01.500Z"), + endTime: new Date("2026-03-06T11:00:02.000Z"), + }, + { + id: "tool-browser-1", + name: "browser_navigate", + arguments: JSON.stringify({ url: "https://www.rokid.com/glasses" }), + status: "running", + startTime: new Date("2026-03-06T11:00:02.500Z"), + }, + ], + }, + ], + isSending: true, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + renderPage({ + projectId: "project-theme-search-browser", + contentId: "content-theme-search-browser", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + workflowSteps?: Array<{ title: string; status: string }>; + } + | undefined; + const workflowSteps = latestInputbarProps?.workflowSteps || []; + + expect(workflowSteps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "检索 Rokid Glasses 最新功能", status: "completed" }), + expect.objectContaining({ title: "打开 https://www.rokid.com/glasses", status: "active" }), + ]), + ); + }); + + it("主题工作台应将点击、截图与命令工具映射为业务化标题", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [ + { + id: "user-3", + role: "user", + content: "/social_post_with_cover 请继续完善并导出发布版", + timestamp: new Date("2026-03-06T12:00:00.000Z"), + }, + { + id: "assistant-3", + role: "assistant", + content: "", + timestamp: new Date("2026-03-06T12:00:01.000Z"), + isThinking: true, + toolCalls: [ + { + id: "tool-click-1", + name: "browser_click", + arguments: JSON.stringify({ element: "发布按钮" }), + status: "completed", + startTime: new Date("2026-03-06T12:00:01.500Z"), + endTime: new Date("2026-03-06T12:00:02.000Z"), + }, + { + id: "tool-snapshot-1", + name: "browser_snapshot", + arguments: JSON.stringify({ element: "结果区域" }), + status: "completed", + startTime: new Date("2026-03-06T12:00:02.500Z"), + endTime: new Date("2026-03-06T12:00:03.000Z"), + }, + { + id: "tool-bash-1", + name: "bash", + arguments: JSON.stringify({ command: "ffmpeg -i input.mp4 output.mp4" }), + status: "running", + startTime: new Date("2026-03-06T12:00:03.500Z"), + }, + ], + }, + ], + isSending: true, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + renderPage({ + projectId: "project-theme-browser-bash", + contentId: "content-theme-browser-bash", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + workflowSteps?: Array<{ title: string; status: string }>; + } + | undefined; + const workflowSteps = latestInputbarProps?.workflowSteps || []; + + expect(workflowSteps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "点击「发布按钮」", status: "completed" }), + expect.objectContaining({ title: "分析页面区域:结果区域", status: "completed" }), + expect.objectContaining({ title: "处理音视频素材", status: "active" }), + ]), + ); + }); + + it("主题工作台运行中应优先使用后端 current_gate_key", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "auto_running", + current_gate_key: "publish_confirm", + queue_items: [ + { + run_id: "run-publish", + title: "选题调研中(用于验证 current_gate_key 优先级)", + gate_key: "topic_select", + status: "running", + source: "skill", + source_ref: null, + started_at: "2026-03-06T03:00:00.000Z", + }, + ], + latest_terminal: null, + updated_at: "2026-03-06T03:00:10.000Z", + }); + + renderPage({ + projectId: "project-theme-gate-priority", + contentId: "content-theme-gate-priority", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + themeWorkbenchGate?: { key?: string }; + workflowSteps?: Array<{ title: string; status: string }>; + } + | undefined; + expect(latestInputbarProps?.themeWorkbenchGate?.key).toBe("publish_confirm"); + const workflowSteps = latestInputbarProps?.workflowSteps || []; + expect(workflowSteps.length).toBeGreaterThan(0); + expect(workflowSteps.at(-1)?.status).toBe("active"); + if (workflowSteps.length > 1) { + expect(workflowSteps[0]?.status).toBe("completed"); + } + }); + + it("主题工作台应基于 execution_id 将工具日志映射到真实 runId", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + activityLogs: [ + { + id: "exec-map-1-social-write-exec-map-1-1a2b3c4d", + messageId: "exec-map-1", + name: "write_file", + status: "completed", + timeLabel: "10:30", + applyTarget: "主稿内容", + contextIds: ["material:1"], + }, + ], + }), + ); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "auto_running", + current_gate_key: "write_mode", + queue_items: [ + { + run_id: "run-map-1", + execution_id: "exec-map-1", + title: "写作阶段", + gate_key: "write_mode", + status: "running", + source: "skill", + source_ref: null, + started_at: "2026-03-06T04:00:00.000Z", + }, + ], + latest_terminal: null, + updated_at: "2026-03-06T04:00:02.000Z", + }); + + const container = renderPage({ + projectId: "project-theme-run-map", + contentId: "content-theme-run-map", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + const sidebar = container.querySelector( + '[data-testid="theme-workbench-sidebar"]', + ) as HTMLElement | null; + expect(sidebar).toBeTruthy(); + expect(sidebar?.getAttribute("data-activity-runs")).toContain("run-map-1"); + expect(sidebar?.getAttribute("data-activity-executions")).toContain( + "exec-map-1", + ); + }); }); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index a1bcd48e1..dd0439590 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -6,16 +6,41 @@ * 当主题为 general 时,使用 GeneralChat 组件实现 */ -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { + startTransition, + useState, + useCallback, + useMemo, + useEffect, + useRef, + memo, + type ReactNode, +} from "react"; import { toast } from "sonner"; import styled from "styled-components"; +import { PanelLeftOpen } from "lucide-react"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { invoke } from "@tauri-apps/api/core"; -import { useAgentChatUnified } from "./hooks"; +import { safeListen } from "@/lib/dev-bridge"; +import { + uploadImageToSession, + importDocument, +} from "@/lib/api/session-files"; +import { + useAgentChatUnified, + useThemeContextWorkspace, + useTopicBranchBoard, +} from "./hooks"; +import type { SidebarActivityLog } from "./hooks/useThemeContextWorkspace"; +import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard"; import { useSessionFiles } from "./hooks/useSessionFiles"; import { useContentSync } from "./hooks/useContentSync"; import { ChatNavbar } from "./components/ChatNavbar"; import { ChatSidebar } from "./components/ChatSidebar"; +import { + ThemeWorkbenchSidebar, + type ThemeWorkbenchCreationTaskEvent, +} from "./components/ThemeWorkbenchSidebar"; import { MessageList } from "./components/MessageList"; import { Inputbar } from "./components/Inputbar"; import { EmptyState } from "./components/EmptyState"; @@ -30,6 +55,12 @@ import { type CanvasStateUnion, } from "@/components/content-creator/canvas/canvasUtils"; import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import type { + AutoContinueRunPayload, + ContentReviewRunPayload, + DocumentVersion, + TextStylizeRunPayload, +} from "@/components/content-creator/canvas/document/types"; import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/canvas"; import { type CanvasState as GeneralCanvasState, @@ -58,9 +89,11 @@ import { getDefaultProject, getOrCreateDefaultProject, getContent, + getThemeWorkbenchDocumentState, updateContent, type Project, type ProjectType, + type ThemeWorkbenchDocumentState, } from "@/lib/api/project"; import { getProjectMemory, @@ -72,6 +105,13 @@ import { SettingsTabs } from "@/types/settings"; import { skillsApi, type Skill } from "@/lib/api/skills"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge"; +import { + executionRunGet, + executionRunGetThemeWorkbenchState, + type AgentRun, + type ThemeWorkbenchRunTodoItem, + type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, +} from "@/lib/api/executionRun"; import { setActiveContentTarget } from "@/lib/activeContentTarget"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; @@ -85,8 +125,16 @@ import { loadRememberedBaseModel, saveRememberedBaseModel, } from "@/lib/model/thinkingBaseModelMemory"; +import type { + AutoContinueRequestPayload, + ToolCallState, +} from "@/lib/api/agent"; +import { + skillExecutionApi, + type SkillDetailInfo, +} from "@/lib/api/skill-execution"; -import type { MessageImage } from "./types"; +import type { Message, MessageImage } from "./types"; import type { ThemeType, LayoutMode, @@ -102,6 +150,13 @@ import { saveChatToolPreferences, type ChatToolPreferences, } from "./utils/chatToolPreferences"; +import { + resolveCanvasTaskFileTarget, + shouldDeferCanvasSyncWhileEditing, +} from "./utils/taskFileCanvasSync"; +import { parseSkillSlashCommand } from "./hooks/skillCommand"; +import { subscribeDocumentEditorFocus } from "@/lib/documentEditorFocusEvents"; +import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "general", @@ -127,6 +182,7 @@ const PageContainer = styled.div` display: flex; height: 100%; width: 100%; + position: relative; `; const MainArea = styled.div` @@ -136,6 +192,7 @@ const MainArea = styled.div` min-width: 0; min-height: 0; overflow: hidden; + position: relative; `; const ChatContainer = styled.div` @@ -163,6 +220,122 @@ const ChatContent = styled.div` padding: 0 6px; overflow: hidden; height: 100%; + position: relative; +`; + +const MessageViewport = styled.div` + flex: 1; + min-height: 0; + overflow: hidden; + padding-bottom: 128px; +`; + +const FloatingInputbarContainer = styled.div` + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + z-index: 20; + pointer-events: none; + + > * { + pointer-events: auto; + } +`; + +const ThemeWorkbenchInputOverlay = styled.div` + position: absolute; + left: 24px; + right: 24px; + bottom: 20px; + z-index: 25; + pointer-events: none; + display: flex; + justify-content: center; + box-sizing: border-box; + + > * { + pointer-events: auto; + width: min(calc(100% - 16px), 480px); + max-width: 100%; + } +`; + +const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>` + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding-bottom: ${({ $bottomInset }) => $bottomInset}; + transition: padding-bottom 0.2s ease; +`; + +const ThemeWorkbenchCanvasHost = styled.div` + flex: 1; + min-height: 0; + + > * { + height: 100%; + } +`; + +interface LayoutTransitionRenderGateProps { + mode: LayoutMode; + chatContent: ReactNode; + canvasContent: ReactNode; +} + +const LayoutTransitionRenderGate = memo( + ({ mode, chatContent, canvasContent }: LayoutTransitionRenderGateProps) => ( + + + + ), + (previous, next) => + previous.mode === next.mode && + previous.chatContent === next.chatContent && + previous.canvasContent === next.canvasContent, +); +LayoutTransitionRenderGate.displayName = "LayoutTransitionRenderGate"; + +interface HandleSendObserver { + onComplete?: (content: string) => void; + onError?: (message: string) => void; +} + +interface HandleSendOptions { + skipThemeSkillPrefix?: boolean; + purpose?: "content_review"; + observer?: HandleSendObserver; +} + +const ThemeWorkbenchLeftExpandButton = styled.button` + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 72px; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--background) / 0.95); + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 30; + + &:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--primary) / 0.4); + background: hsl(var(--accent) / 0.55); + } `; /** @@ -180,6 +353,990 @@ function projectTypeToTheme(projectType: ProjectType): ThemeType { const LAST_PROJECT_ID_KEY = "agent_last_project_id"; const TOPIC_PROJECT_KEY_PREFIX = "agent_session_workspace_"; +const THEME_WORKBENCH_DOCUMENT_META_KEY = "theme_workbench_document_v1"; +const MAX_PERSISTED_DOCUMENT_VERSIONS = 40; +const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; +const THEME_WORKBENCH_CREATION_TASK_EVENT_NAME = + "proxycast://creation_task_submitted"; +const MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS = 120; + +interface CreationTaskSubmittedPayload { + task_id?: string; + task_type?: string; + path?: string; + absolute_path?: string; +} + +function normalizeThemeWorkbenchCreationTaskEvent( + payload: CreationTaskSubmittedPayload, +): ThemeWorkbenchCreationTaskEvent | null { + const taskId = payload.task_id?.trim(); + const taskType = payload.task_type?.trim(); + const path = payload.path?.trim(); + if (!taskId || !taskType || !path) { + return null; + } + const createdAt = Date.now(); + return { + taskId, + taskType, + path, + absolutePath: payload.absolute_path?.trim() || undefined, + createdAt, + timeLabel: new Date(createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + }; +} + +function resolveThemeWorkbenchRunStepStatus( + status: "queued" | "running" | "success" | "error" | "canceled" | "timeout", +): StepStatus { + if (status === "running") { + return "active"; + } + if (status === "queued") { + return "pending"; + } + if (status === "success") { + return "completed"; + } + return "error"; +} + +function parseThemeWorkbenchToolArguments( + argumentsJson?: string, +): Record { + if (!argumentsJson) { + return {}; + } + + try { + const parsed = JSON.parse(argumentsJson); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function truncateThemeWorkbenchLabel(value: string, limit = 28): string { + return value.length > limit ? `${value.slice(0, limit)}…` : value; +} + +function resolveThemeWorkbenchTextArg( + args: Record, + keys: string[], +): string { + for (const key of keys) { + const value = args[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (Array.isArray(value)) { + const firstString = value.find( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ); + if (firstString) { + return firstString.trim(); + } + } + } + return ""; +} + +function getThemeWorkbenchFileLabel(pathValue: string): string { + const normalized = pathValue.trim(); + if (!normalized) { + return "主稿文件"; + } + const segments = normalized.split(/[/\\]/).filter(Boolean); + if (segments.length >= 2) { + return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`; + } + return segments[0] || normalized; +} + +function resolveThemeWorkbenchToolTaskTitle(toolCall: ToolCallState): string { + const normalized = toolCall.name.trim().toLowerCase(); + const args = parseThemeWorkbenchToolArguments(toolCall.arguments); + const queryValue = resolveThemeWorkbenchTextArg(args, [ + "query", + "q", + "keyword", + "pattern", + "text", + ]); + const urlValue = resolveThemeWorkbenchTextArg(args, ["url", "href"]); + const elementValue = resolveThemeWorkbenchTextArg(args, [ + "element", + "name", + "label", + "ref", + ]); + + if (normalized.includes("social_generate_cover_image")) { + const size = resolveThemeWorkbenchTextArg(args, ["size"]); + return size ? `生成封面图(${size})` : "生成封面图"; + } + if (normalized.includes("write_file") || normalized.includes("create_file")) { + const pathValue = resolveThemeWorkbenchTextArg(args, [ + "path", + "file_path", + "filePath", + ]); + return pathValue + ? `写入 ${getThemeWorkbenchFileLabel(pathValue)}` + : "写入主稿文件"; + } + if ( + normalized.includes("websearch") || + normalized.includes("search_query") || + normalized.includes("web_search") || + normalized.includes("search") + ) { + return queryValue + ? `检索 ${truncateThemeWorkbenchLabel(queryValue)}` + : "检索参考资料"; + } + if ( + normalized.includes("browser_navigate") || + (normalized.includes("navigate") && urlValue) + ) { + return urlValue + ? `打开 ${truncateThemeWorkbenchLabel(urlValue, 36)}` + : "打开网页"; + } + if (normalized.includes("browser_click") || normalized === "click") { + return elementValue + ? `点击「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "点击页面元素"; + } + if (normalized.includes("browser_hover") || normalized === "hover") { + return elementValue + ? `定位「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "定位页面元素"; + } + if (normalized.includes("browser_type") || normalized === "type") { + return elementValue + ? `填写「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : queryValue + ? `填写 ${truncateThemeWorkbenchLabel(queryValue, 18)}` + : "填写页面内容"; + } + if ( + normalized.includes("browser_select_option") || + normalized.includes("select_option") + ) { + const value = resolveThemeWorkbenchTextArg(args, [ + "value", + "values", + "option", + ]); + return value + ? `选择 ${truncateThemeWorkbenchLabel(value, 20)}` + : elementValue + ? `选择「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "选择页面选项"; + } + if ( + normalized.includes("browser_press_key") || + normalized.includes("press_key") + ) { + const keyValue = resolveThemeWorkbenchTextArg(args, ["key"]); + return keyValue ? `触发按键 ${keyValue}` : "触发页面快捷键"; + } + if (normalized.includes("browser_drag") || normalized.includes("drag")) { + const endValue = resolveThemeWorkbenchTextArg(args, [ + "endElement", + "endRef", + ]); + return endValue + ? `拖拽到「${truncateThemeWorkbenchLabel(endValue, 18)}」` + : "拖拽页面元素"; + } + if ( + normalized.includes("browser_snapshot") || + normalized.includes("screenshot") + ) { + return elementValue + ? `分析页面区域:${truncateThemeWorkbenchLabel(elementValue, 20)}` + : urlValue + ? `分析页面 ${truncateThemeWorkbenchLabel(urlValue, 30)}` + : "分析页面内容"; + } + if (normalized.includes("bash") || normalized.includes("shell")) { + const commandValue = resolveThemeWorkbenchTextArg(args, ["command", "cmd"]); + const commandProbe = commandValue.toLowerCase(); + if (commandProbe.includes("ffmpeg")) { + return "处理音视频素材"; + } + if (commandProbe.includes("curl") || commandProbe.includes("wget")) { + return "下载远程资源"; + } + if ( + commandProbe.includes("python") || + commandProbe.includes("node") || + commandProbe.includes("tsx") || + commandProbe.includes("npm") + ) { + return "执行自动化脚本"; + } + return commandValue + ? `执行命令:${truncateThemeWorkbenchLabel(commandValue, 22)}` + : "执行终端命令"; + } + if (normalized.includes("browser")) { + return urlValue + ? `采集 ${truncateThemeWorkbenchLabel(urlValue, 36)}` + : elementValue + ? `处理页面元素:${truncateThemeWorkbenchLabel(elementValue, 20)}` + : "采集网页信息"; + } + return toolCall.name.replace(/[_-]+/g, " ").trim() || "执行工具"; +} + +function resolveThemeWorkbenchPrimaryTaskTitle( + skillName: string, + detail?: SkillDetailInfo | null, +): string { + if (skillName === SOCIAL_ARTICLE_SKILL_KEY) { + return "生成社媒主稿"; + } + + const displayName = detail?.display_name?.trim(); + if (displayName) { + return displayName; + } + + return skillName.replace(/[_-]+/g, " ").trim() || "执行任务"; +} + +function extractThemeWorkbenchWorkflowMarkerIndex( + content: string, +): number | null { + const matches = [...content.matchAll(/\*\*步骤\s+(\d+)\/(\d+):/g)]; + if (matches.length === 0) { + return null; + } + const last = matches[matches.length - 1]; + const value = Number(last[1]); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return value - 1; +} + +function findLatestThemeWorkbenchExecution(messages: Message[]): { + assistantMessage: Message; + skillName: string | null; +} | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "assistant") { + continue; + } + + const hasToolCalls = (message.toolCalls?.length || 0) > 0; + const hasPendingAction = + message.actionRequests?.some( + (request) => request.status !== "submitted", + ) || false; + if (!message.isThinking && !hasToolCalls && !hasPendingAction) { + continue; + } + + let skillName: string | null = null; + for (let userIndex = index - 1; userIndex >= 0; userIndex -= 1) { + const candidate = messages[userIndex]; + if (candidate.role !== "user") { + continue; + } + skillName = parseSkillSlashCommand(candidate.content)?.skillName || null; + break; + } + + return { + assistantMessage: message, + skillName, + }; + } + + return null; +} + +function buildThemeWorkbenchLiveWorkflowSteps( + messages: Message[], + skillDetailMap: Record, + isSending: boolean, +): Array<{ id: string; title: string; status: StepStatus }> { + const activeExecution = findLatestThemeWorkbenchExecution(messages); + if (!activeExecution) { + return []; + } + + const { assistantMessage, skillName } = activeExecution; + if (!skillName) { + return []; + } + + const skillDetail = skillDetailMap[skillName] || null; + const workflowSteps = skillDetail?.workflow_steps || []; + if (workflowSteps.length > 0) { + const activeIndex = + extractThemeWorkbenchWorkflowMarkerIndex( + assistantMessage.content || "", + ) ?? 0; + return workflowSteps.map((step, index) => ({ + id: step.id, + title: step.name, + status: + index < activeIndex + ? ("completed" as StepStatus) + : index == activeIndex + ? ("active" as StepStatus) + : ("pending" as StepStatus), + })); + } + + const toolCalls = assistantMessage.toolCalls || []; + const steps: Array<{ id: string; title: string; status: StepStatus }> = []; + const primaryTaskTitle = resolveThemeWorkbenchPrimaryTaskTitle( + skillName, + skillDetail, + ); + const hasRunningTool = toolCalls.some( + (toolCall) => toolCall.status === "running", + ); + const hasFailedTool = toolCalls.some( + (toolCall) => toolCall.status === "failed", + ); + const hasCompletedPrimaryWrite = toolCalls.some((toolCall) => { + if (toolCall.status !== "completed") { + return false; + } + const normalizedName = toolCall.name.trim().toLowerCase(); + return ( + normalizedName.includes("write_file") || + normalizedName.includes("create_file") + ); + }); + + steps.push({ + id: `${skillName}:primary`, + title: primaryTaskTitle, + status: hasCompletedPrimaryWrite + ? ("completed" as StepStatus) + : hasFailedTool + ? ("error" as StepStatus) + : toolCalls.length > 0 + ? ("completed" as StepStatus) + : assistantMessage.isThinking || isSending + ? ("active" as StepStatus) + : ("pending" as StepStatus), + }); + + toolCalls.forEach((toolCall, index) => { + steps.push({ + id: toolCall.id || `${skillName}:tool:${index}`, + title: resolveThemeWorkbenchToolTaskTitle(toolCall), + status: + toolCall.status === "running" + ? ("active" as StepStatus) + : toolCall.status === "completed" + ? ("completed" as StepStatus) + : ("error" as StepStatus), + }); + }); + + if (isSending && toolCalls.length > 0 && !hasRunningTool) { + steps.push({ + id: `${skillName}:finalize`, + title: "整理最终结果", + status: "active", + }); + } + + return steps; +} + +function resolveThemeWorkbenchQueueItemTitle( + item: ThemeWorkbenchRunTodoItem, + skillDetailMap: Record, +): string { + const sourceRef = item.source_ref?.trim(); + if (sourceRef) { + return resolveThemeWorkbenchPrimaryTaskTitle( + sourceRef, + skillDetailMap[sourceRef], + ); + } + return item.title?.trim() || "执行任务"; +} +const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000; + +interface PersistedThemeWorkbenchDocument { + versions: DocumentVersion[]; + currentVersionId: string; + versionStatusMap: Record; +} + +function isTopicBranchStatus(value: unknown): value is TopicBranchStatus { + return ( + value === "in_progress" || + value === "pending" || + value === "merged" || + value === "candidate" + ); +} + +function normalizeDocumentVersion(value: unknown): DocumentVersion | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; + const content = + typeof candidate.content === "string" ? candidate.content : ""; + const createdAt = + typeof candidate.createdAt === "number" + ? candidate.createdAt + : typeof candidate.created_at === "number" + ? candidate.created_at + : NaN; + const description = + typeof candidate.description === "string" + ? candidate.description + : undefined; + + if (!id || Number.isNaN(createdAt)) { + return null; + } + + return { + id, + content, + createdAt, + description, + }; +} + +function buildPersistedThemeWorkbenchDocument( + state: CanvasStateUnion, + statusMap: Record, +): PersistedThemeWorkbenchDocument | null { + if (state.type !== "document" || state.versions.length === 0) { + return null; + } + + const normalizedVersions = state.versions + .map((version) => normalizeDocumentVersion(version)) + .filter((version): version is DocumentVersion => !!version); + + if (normalizedVersions.length === 0) { + return null; + } + + const latestVersions = normalizedVersions.slice( + -MAX_PERSISTED_DOCUMENT_VERSIONS, + ); + const versionIdSet = new Set(latestVersions.map((version) => version.id)); + let currentVersionId = state.currentVersionId; + + if (!versionIdSet.has(currentVersionId)) { + currentVersionId = + latestVersions[latestVersions.length - 1]?.id || latestVersions[0].id; + } + + const persistedVersions = latestVersions.map((version) => + version.id === currentVersionId ? { ...version, content: "" } : version, + ); + + const versionStatusMap = Object.fromEntries( + Object.entries(statusMap).filter( + ([versionId, status]) => + versionIdSet.has(versionId) && isTopicBranchStatus(status), + ), + ) as Record; + + return { + versions: persistedVersions, + currentVersionId, + versionStatusMap, + }; +} + +function readPersistedThemeWorkbenchDocument( + metadata?: Record, +): PersistedThemeWorkbenchDocument | null { + const raw = metadata?.[THEME_WORKBENCH_DOCUMENT_META_KEY]; + if (!raw || typeof raw !== "object") { + return null; + } + const candidate = raw as Record; + const versionsRaw = Array.isArray(candidate.versions) + ? candidate.versions + : []; + const versions = versionsRaw + .map((version) => normalizeDocumentVersion(version)) + .filter((version): version is DocumentVersion => !!version) + .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + if (versions.length === 0) { + return null; + } + + const versionIdSet = new Set(versions.map((version) => version.id)); + const currentVersionIdRaw = candidate.currentVersionId; + const currentVersionId = + typeof currentVersionIdRaw === "string" && + versionIdSet.has(currentVersionIdRaw) + ? currentVersionIdRaw + : versions[versions.length - 1]?.id || versions[0].id; + + const statusRaw = candidate.versionStatusMap; + const statusEntries = + statusRaw && typeof statusRaw === "object" ? statusRaw : {}; + const versionStatusMap = Object.fromEntries( + Object.entries(statusEntries).filter( + ([versionId, status]) => + versionIdSet.has(versionId) && isTopicBranchStatus(status), + ), + ) as Record; + + return { + versions, + currentVersionId, + versionStatusMap, + }; +} + +function applyBackendThemeWorkbenchDocumentState( + state: CanvasStateUnion, + backendState: ThemeWorkbenchDocumentState, + currentBody: string, +): { + state: CanvasStateUnion; + statusMap: Record; +} | null { + if (state.type !== "document" || backendState.versions.length === 0) { + return null; + } + + const versions = backendState.versions + .map((version, index) => ({ + id: version.id, + content: version.is_current ? currentBody : "", + createdAt: version.created_at, + description: version.description?.trim() || `版本 ${index + 1}`, + })) + .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + + if (versions.length === 0) { + return null; + } + + const currentVersion = + versions.find( + (version) => version.id === backendState.current_version_id, + ) || versions[versions.length - 1]; + + const statusMap = Object.fromEntries( + backendState.versions + .filter( + ( + version, + ): version is ThemeWorkbenchDocumentState["versions"][number] & { + status: TopicBranchStatus; + } => isTopicBranchStatus(version.status), + ) + .map((version) => [version.id, version.status]), + ) as Record; + + return { + state: { + ...state, + versions, + currentVersionId: currentVersion.id, + content: currentVersion.content, + }, + statusMap, + }; +} + +function inferThemeWorkbenchGateFromQueueItem( + queueItem: ThemeWorkbenchRunTodoItem | null, +): { + key: "topic_select" | "write_mode" | "publish_confirm"; + title: string; + description: string; +} { + const gateKey = queueItem?.gate_key; + if (gateKey === "publish_confirm") { + return { + key: "publish_confirm", + title: "发布闸门", + description: queueItem?.title || "正在准备发布前检查与平台适配结果。", + }; + } + if (gateKey === "topic_select") { + return { + key: "topic_select", + title: "选题闸门", + description: queueItem?.title || "正在整理选题方向并生成可确认方案。", + }; + } + if (gateKey === "write_mode") { + return { + key: "write_mode", + title: "写作闸门", + description: queueItem?.title || "正在执行主稿写作与插图生成流程。", + }; + } + + if (!queueItem) { + return { + key: "topic_select", + title: "选题闸门", + description: "正在整理选题方向并生成可确认方案。", + }; + } + + const probe = + `${queueItem.title} ${queueItem.source_ref || ""} ${queueItem.source}`.toLowerCase(); + const looksLikePublish = + /publish|adapt|distribution|release|发布|分发|平台适配/.test(probe); + if (looksLikePublish) { + return { + key: "publish_confirm", + title: "发布闸门", + description: queueItem.title || "正在准备发布前检查与平台适配结果。", + }; + } + + const looksLikeTopic = /topic|research|trend|idea|选题|方向|调研|洞察/.test( + probe, + ); + if (looksLikeTopic) { + return { + key: "topic_select", + title: "选题闸门", + description: queueItem.title || "正在整理选题方向并生成可确认方案。", + }; + } + + return { + key: "write_mode", + title: "写作闸门", + description: queueItem.title || "正在执行主稿写作与插图生成流程。", + }; +} + +function resolveThemeWorkbenchGateByKey( + gateKey: "topic_select" | "write_mode" | "publish_confirm", + fallbackTitle?: string, +): { + key: "topic_select" | "write_mode" | "publish_confirm"; + title: string; + description: string; +} { + if (gateKey === "publish_confirm") { + return { + key: "publish_confirm", + title: "发布闸门", + description: fallbackTitle || "正在准备发布前检查与平台适配结果。", + }; + } + if (gateKey === "topic_select") { + return { + key: "topic_select", + title: "选题闸门", + description: fallbackTitle || "正在整理选题方向并生成可确认方案。", + }; + } + return { + key: "write_mode", + title: "写作闸门", + description: fallbackTitle || "正在执行主稿写作与插图生成流程。", + }; +} + +function formatThemeWorkbenchRunTimeLabel( + raw: string | null | undefined, +): string { + if (!raw) { + return "--:--"; + } + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return "--:--"; + } + return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatThemeWorkbenchRunDurationLabel( + startedAt: string | null | undefined, + finishedAt: string | null | undefined, +): string | undefined { + if (!startedAt || !finishedAt) { + return undefined; + } + + const started = new Date(startedAt); + const finished = new Date(finishedAt); + if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) { + return undefined; + } + + const durationMs = finished.getTime() - started.getTime(); + if (durationMs < 0) { + return undefined; + } + if (durationMs < 1000) { + return `${durationMs}ms`; + } + if (durationMs < 60000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + return `${Math.floor(durationMs / 60000)}m${Math.round( + (durationMs % 60000) / 1000, + )}s`; +} + +function resolveThemeWorkbenchApplyTargetByGateKey( + gateKey: "topic_select" | "write_mode" | "publish_confirm" | "idle", +): string { + if (gateKey === "topic_select") { + return "选题池"; + } + if (gateKey === "publish_confirm") { + return "发布产物"; + } + if (gateKey === "write_mode") { + return "版本主稿"; + } + return "主稿内容"; +} + +function extractExecutionIdFromSocialToolId(toolCallId: string): string | null { + const normalized = toolCallId.trim(); + if (!normalized.startsWith("social-write-")) { + return null; + } + const match = normalized.match(/^social-write-(.+)-[0-9a-f]{8}$/i); + const executionId = match?.[1]?.trim(); + if (!executionId) { + return null; + } + return executionId; +} + +function resolveExecutionIdCandidatesForActivityLog( + log: SidebarActivityLog, +): string[] { + const candidates: string[] = []; + const pushCandidate = (value?: string | null) => { + const normalized = value?.trim(); + if (!normalized) { + return; + } + if (!candidates.includes(normalized)) { + candidates.push(normalized); + } + }; + + pushCandidate(log.executionId); + pushCandidate(log.messageId); + + const normalizedLogId = log.id.trim(); + if (normalizedLogId) { + let toolCallIdProbe = normalizedLogId; + if (log.messageId) { + const messagePrefix = `${log.messageId}-`; + if (normalizedLogId.startsWith(messagePrefix)) { + toolCallIdProbe = normalizedLogId.slice(messagePrefix.length); + } + } + pushCandidate(extractExecutionIdFromSocialToolId(toolCallIdProbe)); + } + + return candidates; +} + +function isThemeWorkbenchPrimaryDocumentArtifact(fileName: string): boolean { + const normalized = fileName.trim().toLowerCase(); + if (!normalized) { + return false; + } + return normalized.endsWith(".md") || normalized.endsWith(".markdown"); +} + +function inferTaskFileType(fileName: string): TaskFile["type"] { + const normalized = fileName.trim().toLowerCase(); + const extension = normalized.split(".").pop() || ""; + + if (extension === "md" || extension === "markdown" || extension === "txt") { + return "document"; + } + if ( + ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico"].includes( + extension, + ) + ) { + return "image"; + } + if ( + ["mp3", "wav", "aac", "flac", "m4a", "ogg", "mid", "midi"].includes( + extension, + ) + ) { + return "audio"; + } + if (["mp4", "mov", "avi", "mkv", "webm"].includes(extension)) { + return "video"; + } + return "other"; +} + +function looksLikeSocialPublishPayload(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false; + } + + try { + const parsed = JSON.parse(trimmed) as Record; + return ( + typeof parsed.article_path === "string" || + typeof parsed.cover_meta_path === "string" || + Array.isArray(parsed.pipeline) || + Array.isArray(parsed.recommended_channels) + ); + } catch { + return false; + } +} + +function looksLikeThemeWorkbenchErrorPayload(content: string): boolean { + const normalized = content.trim().toLowerCase(); + if (!normalized) { + return false; + } + + return ( + normalized.startsWith("ran into this error:") || + normalized.startsWith("request failed:") || + normalized.includes( + "please retry if you think this is a transient or recoverable error.", + ) || + normalized.includes("api key not valid") + ); +} + +function isCorruptedThemeWorkbenchDocumentContent( + content?: string | null, +): boolean { + if (typeof content !== "string") { + return false; + } + + return ( + looksLikeSocialPublishPayload(content) || + looksLikeThemeWorkbenchErrorPayload(content) + ); +} + +function resolveTaskFileType( + fileName: string, + content?: string | null, +): TaskFile["type"] { + const inferredType = inferTaskFileType(fileName); + if ( + inferredType === "document" && + isCorruptedThemeWorkbenchDocumentContent(content) + ) { + return "other"; + } + return inferredType; +} + +function normalizeSessionTaskFileType( + fileType: string, + fileName: string, + content?: string | null, +): TaskFile["type"] { + const normalized = fileType.trim().toLowerCase(); + if ( + normalized === "document" || + normalized === "image" || + normalized === "audio" || + normalized === "video" || + normalized === "other" + ) { + const resolvedByContent = resolveTaskFileType(fileName, content); + if (normalized === "document" && resolvedByContent !== "document") { + return resolvedByContent; + } + return normalized; + } + return resolveTaskFileType(fileName, content); +} + +function isRenderableTaskFile( + file: Pick, + isThemeWorkbench: boolean, +): boolean { + if (file.type !== "document") { + return false; + } + if (!isThemeWorkbench) { + return true; + } + return isThemeWorkbenchPrimaryDocumentArtifact(file.name); +} + +function buildThemeWorkbenchWorkflowSteps( + messages: Message[], + backendRunState: BackendThemeWorkbenchRunState | null, + isSending: boolean, + skillDetailMap: Record, +): Array<{ id: string; title: string; status: StepStatus }> { + const liveSteps = buildThemeWorkbenchLiveWorkflowSteps( + messages, + skillDetailMap, + isSending, + ); + if (liveSteps.length > 0) { + return liveSteps; + } + + const queueItems = backendRunState?.queue_items || []; + if (queueItems.length > 0) { + return queueItems.map((item) => ({ + id: item.run_id, + title: resolveThemeWorkbenchQueueItemTitle(item, skillDetailMap), + status: resolveThemeWorkbenchRunStepStatus(item.status), + })); + } + + const latestTerminal = backendRunState?.latest_terminal; + if (latestTerminal && backendRunState?.run_state !== "auto_running") { + return [ + { + id: latestTerminal.run_id, + title: resolveThemeWorkbenchQueueItemTitle( + latestTerminal, + skillDetailMap, + ), + status: resolveThemeWorkbenchRunStepStatus(latestTerminal.status), + }, + ]; + } + + return []; +} function loadPersistedProjectId(key: string): string | null { try { @@ -261,6 +1418,66 @@ function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean { } } +function serializeCanvasStateForSync(state: CanvasStateUnion): string { + switch (state.type) { + case "document": + return state.content || ""; + case "novel": + return JSON.stringify(state.chapters); + case "script": + return JSON.stringify(state.scenes); + case "music": + return JSON.stringify(state.sections); + case "poster": + return JSON.stringify(state.pages); + default: + return JSON.stringify(state); + } +} + +function isSyncContentEmpty(content: string): boolean { + return !content || content === "[]" || content === "{}"; +} + +function buildThemeWorkbenchRunStateSignature( + state: BackendThemeWorkbenchRunState | null, +): string { + if (!state) { + return "null"; + } + + const queueSignature = (state.queue_items || []) + .map((item) => + [ + item.run_id, + item.execution_id || "", + item.status, + item.gate_key || "", + item.source || "", + item.source_ref || "", + ].join(":"), + ) + .join("|"); + + const terminalSignature = state.latest_terminal + ? [ + state.latest_terminal.run_id, + state.latest_terminal.execution_id || "", + state.latest_terminal.status, + state.latest_terminal.gate_key || "", + state.latest_terminal.source || "", + state.latest_terminal.source_ref || "", + ].join(":") + : ""; + + return [ + state.run_state, + state.current_gate_key || "", + queueSignature, + terminalSignature, + ].join("||"); +} + export function AgentChatPage({ onNavigate: _onNavigate, projectId: externalProjectId, @@ -281,6 +1498,7 @@ export function AgentChatPage({ onRecommendationClick: _onRecommendationClick, onHasMessagesChange, onSessionChange, + preferContentReviewInRightRail = false, }: { onNavigate?: (page: Page, params?: PageParams) => void; projectId?: string; @@ -303,21 +1521,34 @@ export function AgentChatPage({ onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; onHasMessagesChange?: (hasMessages: boolean) => void; onSessionChange?: (sessionId: string | null) => void; + preferContentReviewInRightRail?: boolean; }) { const [showSidebar, setShowSidebar] = useState(true); + const [themeWorkbenchSidebarCollapsed, setThemeWorkbenchSidebarCollapsed] = + useState(false); const [input, setInput] = useState(""); const [selectedText, setSelectedText] = useState(""); const [chatToolPreferences, setChatToolPreferences] = useState(() => loadChatToolPreferences()); + const normalizedEntryTheme = normalizeInitialTheme(initialTheme); + const shouldBootstrapCanvasOnEntry = + Boolean(contentId) && isContentCreationTheme(normalizedEntryTheme); + // 内容创作相关状态 - const [activeTheme, setActiveTheme] = useState( - normalizeInitialTheme(initialTheme), - ); + const [activeTheme, setActiveTheme] = useState(normalizedEntryTheme); const [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); - const [layoutMode, setLayoutMode] = useState("chat"); + const [layoutMode, setLayoutMode] = useState( + shouldBootstrapCanvasOnEntry ? "canvas" : "chat", + ); + const [isInitialContentLoading, setIsInitialContentLoading] = useState( + shouldBootstrapCanvasOnEntry, + ); + const [initialContentLoadError, setInitialContentLoadError] = useState< + string | null + >(null); useEffect(() => { if (!initialTheme) return; @@ -337,19 +1568,75 @@ export function AgentChatPage({ const [internalProjectId, setInternalProjectId] = useState( null, ); + const handledNewChatRequestRef = useRef(null); + + const incomingNewChatRequestKey = + typeof newChatAt === "number" ? String(newChatAt) : null; + const shouldResetToFreshHomeContext = + !externalProjectId && + incomingNewChatRequestKey !== null && + handledNewChatRequestRef.current !== incomingNewChatRequestKey; // 使用外部或内部的 projectId - const projectId = externalProjectId ?? internalProjectId ?? undefined; + const projectId = + externalProjectId ?? + (shouldResetToFreshHomeContext ? undefined : internalProjectId) ?? + undefined; // 画布状态(支持多种画布类型) - const [canvasState, setCanvasState] = useState(null); + const [canvasState, setCanvasState] = useState( + () => { + if (!shouldBootstrapCanvasOnEntry) { + return null; + } + + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + }, + ); + const [documentVersionStatusMap, setDocumentVersionStatusMap] = useState< + Record + >({}); + const contentMetadataRef = useRef>({}); + const persistedWorkbenchSnapshotRef = useRef(""); + const lastCanvasSyncRequestRef = useRef<{ + contentId: string; + body: string; + } | null>(null); + const themeWorkbenchRunStateSignatureRef = useRef(""); const [novelChapterListCollapsed, setNovelChapterListCollapsed] = useState(false); + const [themeWorkbenchBackendRunState, setThemeWorkbenchBackendRunState] = + useState(null); + const [themeWorkbenchSkillDetailMap, setThemeWorkbenchSkillDetailMap] = + useState>({}); + const [selectedThemeWorkbenchRunId, setSelectedThemeWorkbenchRunId] = + useState(null); + const [selectedThemeWorkbenchRunDetail, setSelectedThemeWorkbenchRunDetail] = + useState(null); + const [themeWorkbenchRunDetailLoading, setThemeWorkbenchRunDetailLoading] = + useState(false); + const [ + themeWorkbenchCreationTaskEvents, + setThemeWorkbenchCreationTaskEvents, + ] = useState([]); + const documentEditorFocusedRef = useRef(false); useEffect(() => { setActiveContentTarget(projectId, contentId, canvasState?.type ?? null); }, [canvasState?.type, contentId, projectId]); + useEffect(() => { + persistedWorkbenchSnapshotRef.current = ""; + contentMetadataRef.current = {}; + lastCanvasSyncRequestRef.current = null; + if (!contentId) { + setDocumentVersionStatusMap({}); + } + }, [contentId]); + // General 主题专用画布状态 const [generalCanvasState, setGeneralCanvasState] = useState(DEFAULT_CANVAS_STATE); @@ -376,6 +1663,20 @@ export function AgentChatPage({ // 技能列表(用于 @ 引用) const [skills, setSkills] = useState([]); + // Workbench Store(用于主题工作台右侧面板状态同步) + const pendingSkillKey = useWorkbenchStore( + (state) => state.pendingSkillKey, + ); + const setThemeSkillsRailState = useWorkbenchStore( + (state) => state.setThemeSkillsRailState, + ); + const clearThemeSkillsRailState = useWorkbenchStore( + (state) => state.clearThemeSkillsRailState, + ); + const consumePendingSkill = useWorkbenchStore( + (state) => state.consumePendingSkill, + ); + // 用于追踪已处理的消息 ID,避免重复处理 const processedMessageIds = useRef>(new Set()); const pendingTopicSwitchRef = useRef<{ @@ -442,50 +1743,183 @@ export function AgentChatPage({ // 加载项目、Memory 和内容 useEffect(() => { + let cancelled = false; + const loadData = async () => { + if (contentId) { + setIsInitialContentLoading(true); + setInitialContentLoadError(null); + } else { + setIsInitialContentLoading(false); + setInitialContentLoadError(null); + } + if (!projectId) { + if (cancelled) { + return; + } setProject(null); setProjectMemory(null); + setIsInitialContentLoading(false); return; } - // 1. 加载项目 - const p = await getProject(projectId); - if (!p) return; + try { + const p = await getProject(projectId); + if (!p) { + if (cancelled) { + return; + } + setProject(null); + setProjectMemory(null); + if (contentId) { + setInitialContentLoadError("当前项目不存在或已被删除"); + } + return; + } - setProject(p); - // 直接使用 projectType 作为 theme(类型已统一) - const theme = projectTypeToTheme(p.workspaceType); - if (!lockTheme || !initialTheme) { - setActiveTheme(theme); - } + if (cancelled) { + return; + } - // 2. 加载 Memory - const memory = await getProjectMemory(projectId); - setProjectMemory(memory); + setProject(p); + const theme = projectTypeToTheme(p.workspaceType); + if (!lockTheme || !initialTheme) { + setActiveTheme(theme); + } + + const memory = await getProjectMemory(projectId); + if (cancelled) { + return; + } + setProjectMemory(memory); + + if (!contentId) { + return; + } - // 3. 如果有 contentId,加载内容并打开画布 - if (contentId) { const content = await getContent(contentId); - if (content) { - const canvasTheme = ( - lockTheme && initialTheme - ? normalizeInitialTheme(initialTheme) - : theme - ) as ThemeType; + if (cancelled) { + return; + } - const initialState = - createInitialCanvasState(canvasTheme, content.body || "") || - createInitialDocumentState(content.body || ""); - setCanvasState(initialState); - setLayoutMode("chat-canvas"); + if (!content) { + setInitialContentLoadError("文稿不存在或读取失败"); + return; + } + + contentMetadataRef.current = content.metadata || {}; + const canvasTheme = ( + lockTheme && initialTheme + ? normalizeInitialTheme(initialTheme) + : theme + ) as ThemeType; + const rawBody = content.body || ""; + const sanitizedBody = isCorruptedThemeWorkbenchDocumentContent(rawBody) + ? "" + : rawBody; + + if (rawBody && sanitizedBody !== rawBody) { + setInitialContentLoadError("当前文稿未生成有效主稿,请重新生成或稍后重试"); + } else { + setInitialContentLoadError(null); + } + + let initialState = + createInitialCanvasState(canvasTheme, sanitizedBody) || + createInitialDocumentState(sanitizedBody); + + if (initialState.type === "document") { + const backendDocumentState = await getThemeWorkbenchDocumentState( + content.id, + ).catch((error) => { + console.warn( + "[AgentChatPage] 读取主题工作台版本状态失败,降级为 metadata 解析:", + error, + ); + return null; + }); + const backendApplied = backendDocumentState + ? applyBackendThemeWorkbenchDocumentState( + initialState, + backendDocumentState, + sanitizedBody, + ) + : null; + + if (backendApplied) { + initialState = backendApplied.state; + setDocumentVersionStatusMap(backendApplied.statusMap); + } else { + const persisted = readPersistedThemeWorkbenchDocument(content.metadata); + if (persisted) { + const restoredVersions = persisted.versions.map((version) => + version.id === persisted.currentVersionId + ? { ...version, content: sanitizedBody || version.content } + : version, + ); + const currentVersion = + restoredVersions.find( + (version) => version.id === persisted.currentVersionId, + ) || restoredVersions[restoredVersions.length - 1]; + initialState = { + ...initialState, + versions: restoredVersions, + currentVersionId: currentVersion.id, + content: currentVersion.content, + }; + setDocumentVersionStatusMap(persisted.versionStatusMap); + } else { + setDocumentVersionStatusMap({}); + } + } + } else { + setDocumentVersionStatusMap({}); + } + + lastCanvasSyncRequestRef.current = { + contentId: content.id, + body: serializeCanvasStateForSync(initialState), + }; + setCanvasState(initialState); + setLayoutMode("canvas"); + } catch (error) { + console.error("[AgentChatPage] 加载项目或文稿失败:", error); + if (!cancelled && contentId) { + setInitialContentLoadError("文稿加载失败,请稍后重试"); + } + } finally { + if (!cancelled) { + setIsInitialContentLoading(false); } } }; - loadData(); + void loadData(); + + return () => { + cancelled = true; + }; }, [projectId, contentId, lockTheme, initialTheme]); + useEffect(() => { + if (!shouldBootstrapCanvasOnEntry) { + return; + } + + setLayoutMode("canvas"); + setCanvasState((previous) => { + if (previous) { + return previous; + } + + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + }); + }, [normalizedEntryTheme, shouldBootstrapCanvasOnEntry]); + // 当 projectId 变化时主动检查 workspace 目录健康状态 // 静默修复(auto-created)或显示 banner 提示用户重新选择 useEffect(() => { @@ -493,9 +1927,12 @@ export function AgentChatPage({ const normalizedId = normalizeProjectId(projectId); if (!normalizedId) return; - invoke<{ created: boolean; repaired: boolean; rootPath: string }>("workspace_ensure_ready", { - id: normalizedId, - }) + invoke<{ created: boolean; repaired: boolean; rootPath: string }>( + "workspace_ensure_ready", + { + id: normalizedId, + }, + ) .then(({ repaired, rootPath }) => { if (repaired) { recordWorkspaceRepair({ @@ -503,10 +1940,7 @@ export function AgentChatPage({ rootPath, source: "agent_chat_page", }); - console.info( - "[AgentChatPage] workspace 目录已自动修复:", - rootPath, - ); + console.info("[AgentChatPage] workspace 目录已自动修复:", rootPath); } }) .catch((err: unknown) => { @@ -605,6 +2039,835 @@ export function AgentChatPage({ onSessionChange?.(sessionId ?? null); }, [onSessionChange, sessionId]); + const contextWorkspace = useThemeContextWorkspace({ + projectId, + activeTheme, + messages, + providerType, + model, + }); + const isThemeWorkbench = contextWorkspace.enabled; + const enableThemeWorkbenchPanelCollapse = + isThemeWorkbench && mappedTheme === "social-media"; + + // 加载 skills 列表 + useEffect(() => { + let cancelled = false; + skillsApi + .getAll("proxycast") + .then((loadedSkills) => { + if (!cancelled) { + setSkills(loadedSkills); + } + }) + .catch((error) => { + console.warn("[AgentChatPage] 加载 skills 失败:", error); + if (!cancelled) { + setSkills([]); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + // 主题工作台模式:同步 skills 状态到 store + useEffect(() => { + if (!isThemeWorkbench) { + clearThemeSkillsRailState(); + return; + } + + if (skills.length === 0) { + return; + } + + setThemeSkillsRailState({ + skills, + isAutoRunning: isSending, + }); + }, [ + isThemeWorkbench, + skills, + isSending, + setThemeSkillsRailState, + clearThemeSkillsRailState, + ]); + + // 组件卸载时清理 store 状态 + useEffect(() => { + return () => { + clearThemeSkillsRailState(); + }; + }, [clearThemeSkillsRailState]); + + useEffect(() => { + if (!isThemeWorkbench) { + setThemeWorkbenchCreationTaskEvents([]); + } + }, [isThemeWorkbench]); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + return; + } + + setThemeWorkbenchCreationTaskEvents([]); + + let cancelled = false; + let unlisten: (() => void) | null = null; + + safeListen( + THEME_WORKBENCH_CREATION_TASK_EVENT_NAME, + (event) => { + if (cancelled) { + return; + } + const normalized = normalizeThemeWorkbenchCreationTaskEvent( + event.payload || {}, + ); + if (!normalized) { + return; + } + setThemeWorkbenchCreationTaskEvents((previous) => { + const deduplicated = previous.filter( + (item) => + item.taskId !== normalized.taskId && + item.path !== normalized.path, + ); + return [normalized, ...deduplicated].slice( + 0, + MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS, + ); + }); + }, + ) + .then((dispose) => { + if (cancelled) { + void dispose(); + return; + } + unlisten = dispose; + }) + .catch((error) => { + console.warn("[AgentChatPage] 监听任务提交事件失败:", error); + }); + + return () => { + cancelled = true; + if (unlisten) { + unlisten(); + } + }; + }, [isThemeWorkbench, sessionId]); + + useEffect(() => { + if (!isThemeWorkbench || canvasState) { + return; + } + + const initialThemeWorkbenchCanvas = + createInitialCanvasState(mappedTheme, "") || + createInitialDocumentState(""); + if (!initialThemeWorkbenchCanvas) { + return; + } + + setCanvasState(initialThemeWorkbenchCanvas); + setLayoutMode((previous) => (previous === "chat" ? "canvas" : previous)); + }, [canvasState, isThemeWorkbench, mappedTheme]); + + useEffect(() => { + if (enableThemeWorkbenchPanelCollapse) { + return; + } + setThemeWorkbenchSidebarCollapsed(false); + }, [enableThemeWorkbenchPanelCollapse]); + const versionTopics = useMemo(() => { + if (!isThemeWorkbench || !canvasState || canvasState.type !== "document") { + return []; + } + return canvasState.versions.map((version, index) => ({ + id: version.id, + title: version.description?.trim() || `版本 ${index + 1}`, + messagesCount: version.content.trim() ? 2 : 0, + })); + }, [canvasState, isThemeWorkbench]); + const currentVersionId = + isThemeWorkbench && canvasState?.type === "document" + ? canvasState.currentVersionId + : null; + const { branchItems, setTopicStatus } = useTopicBranchBoard({ + enabled: isThemeWorkbench && canvasState?.type === "document", + projectId, + currentTopicId: currentVersionId, + topics: versionTopics, + externalStatusMap: documentVersionStatusMap, + onStatusMapChange: setDocumentVersionStatusMap, + }); + + useEffect(() => { + if ( + !isThemeWorkbench || + !contentId || + !canvasState || + canvasState.type !== "document" + ) { + return; + } + + const persisted = buildPersistedThemeWorkbenchDocument( + canvasState, + documentVersionStatusMap, + ); + if (!persisted) { + return; + } + + const snapshot = JSON.stringify(persisted); + if (snapshot === persistedWorkbenchSnapshotRef.current) { + return; + } + + const nextMetadata = { + ...(contentMetadataRef.current || {}), + [THEME_WORKBENCH_DOCUMENT_META_KEY]: persisted, + }; + + const timer = setTimeout(() => { + updateContent(contentId, { + metadata: nextMetadata, + }) + .then((updated) => { + contentMetadataRef.current = updated.metadata || nextMetadata; + persistedWorkbenchSnapshotRef.current = snapshot; + }) + .catch((error) => { + console.warn("[AgentChatPage] 保存文稿版本状态失败:", error); + }); + }, 1000); + + return () => clearTimeout(timer); + }, [canvasState, contentId, documentVersionStatusMap, isThemeWorkbench]); + + const pendingActionRequest = useMemo(() => { + if (!isThemeWorkbench) { + return null; + } + return ( + [...messages] + .reverse() + .find((message) => + message.actionRequests?.some( + (request) => request.status !== "submitted", + ), + ) + ?.actionRequests?.find((request) => request.status !== "submitted") || + null + ); + }, [isThemeWorkbench, messages]); + + useEffect(() => { + const unsubscribe = subscribeDocumentEditorFocus((focused) => { + documentEditorFocusedRef.current = focused; + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + themeWorkbenchRunStateSignatureRef.current = ""; + setThemeWorkbenchBackendRunState(null); + return; + } + + let disposed = false; + let inFlight = false; + let timer: number | null = null; + const activePollIntervalMs = isSending ? 1000 : 3000; + const idlePollIntervalMs = isSending ? 1000 : 10000; + const focusedPollIntervalMs = isSending ? 1000 : 15000; + + const scheduleNext = (delayMs: number) => { + if (disposed) { + return; + } + timer = window.setTimeout(() => { + void fetchRunState(); + }, delayMs); + }; + + const fetchRunState = async () => { + if (disposed || inFlight) { + return; + } + + inFlight = true; + try { + const state = await executionRunGetThemeWorkbenchState(sessionId, 3); + if (!disposed) { + const nextSignature = buildThemeWorkbenchRunStateSignature(state); + if (themeWorkbenchRunStateSignatureRef.current !== nextSignature) { + themeWorkbenchRunStateSignatureRef.current = nextSignature; + setThemeWorkbenchBackendRunState(state); + } + + const hasFreshRunningQueueItem = (state.queue_items || []).some( + (item) => { + if (item.status !== "running") { + return false; + } + const startedAt = new Date(item.started_at); + if (Number.isNaN(startedAt.getTime())) { + return false; + } + return ( + Date.now() - startedAt.getTime() <= + THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS + ); + }, + ); + + const latestTerminalRunning = + state.latest_terminal?.status === "running"; + const hasActiveBackendRun = + state.run_state === "auto_running" || + hasFreshRunningQueueItem || + latestTerminalRunning; + const isEditorFocused = documentEditorFocusedRef.current; + scheduleNext( + hasActiveBackendRun + ? activePollIntervalMs + : isEditorFocused + ? focusedPollIntervalMs + : idlePollIntervalMs, + ); + } + } catch (error) { + if (!disposed) { + console.warn("[AgentChatPage] 拉取主题工作台运行状态失败:", error); + if (themeWorkbenchRunStateSignatureRef.current !== "null") { + themeWorkbenchRunStateSignatureRef.current = "null"; + setThemeWorkbenchBackendRunState(null); + } + scheduleNext( + documentEditorFocusedRef.current + ? focusedPollIntervalMs + : activePollIntervalMs, + ); + } + } finally { + inFlight = false; + } + }; + + void fetchRunState(); + + return () => { + disposed = true; + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [isSending, isThemeWorkbench, sessionId]); + + const themeWorkbenchRequiredSkillNames = useMemo(() => { + if (!isThemeWorkbench) { + return [] as string[]; + } + + const requiredSkillNames = new Set(); + messages.forEach((message) => { + if (message.role !== "user") { + return; + } + const skillName = parseSkillSlashCommand(message.content)?.skillName; + if (skillName) { + requiredSkillNames.add(skillName); + } + }); + (themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => { + const sourceRef = item.source_ref?.trim(); + if (sourceRef) { + requiredSkillNames.add(sourceRef); + } + }); + const terminalSourceRef = + themeWorkbenchBackendRunState?.latest_terminal?.source_ref?.trim(); + if (terminalSourceRef) { + requiredSkillNames.add(terminalSourceRef); + } + + return [...requiredSkillNames].sort(); + }, [ + isThemeWorkbench, + messages, + themeWorkbenchBackendRunState?.latest_terminal?.source_ref, + themeWorkbenchBackendRunState?.queue_items, + ]); + + useEffect(() => { + if (!isThemeWorkbench) { + setThemeWorkbenchSkillDetailMap((prev) => + Object.keys(prev).length === 0 ? prev : {}, + ); + return; + } + + const missingSkillNames = themeWorkbenchRequiredSkillNames.filter( + (skillName) => !(skillName in themeWorkbenchSkillDetailMap), + ); + if (missingSkillNames.length === 0) { + return; + } + + let disposed = false; + Promise.all( + missingSkillNames.map(async (skillName) => { + try { + const detail = await skillExecutionApi.getSkillDetail(skillName); + return [skillName, detail] as const; + } catch (error) { + console.warn( + "[AgentChatPage] 加载 Skill 详情失败:", + skillName, + error, + ); + return [skillName, null] as const; + } + }), + ).then((entries) => { + if (disposed) { + return; + } + setThemeWorkbenchSkillDetailMap((prev) => { + const next = { ...prev }; + entries.forEach(([skillName, detail]) => { + next[skillName] = detail; + }); + return next; + }); + }); + + return () => { + disposed = true; + }; + }, [ + isThemeWorkbench, + themeWorkbenchRequiredSkillNames, + themeWorkbenchSkillDetailMap, + ]); + + const themeWorkbenchWorkflowSteps = useMemo( + () => + buildThemeWorkbenchWorkflowSteps( + messages, + themeWorkbenchBackendRunState, + isSending, + themeWorkbenchSkillDetailMap, + ), + [ + isSending, + messages, + themeWorkbenchBackendRunState, + themeWorkbenchSkillDetailMap, + ], + ); + + const themeWorkbenchActiveQueueItem = useMemo(() => { + const queueItems = themeWorkbenchBackendRunState?.queue_items || []; + return ( + queueItems.find((item) => item.status === "running") || + queueItems[0] || + null + ); + }, [themeWorkbenchBackendRunState?.queue_items]); + + const themeWorkbenchExecutionRunMap = useMemo(() => { + const map = new Map(); + if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { + return map; + } + + const register = (executionId?: string | null, runId?: string | null) => { + const normalizedExecutionId = executionId?.trim(); + const normalizedRunId = runId?.trim(); + if (!normalizedExecutionId || !normalizedRunId) { + return; + } + map.set(normalizedExecutionId, normalizedRunId); + }; + + (themeWorkbenchBackendRunState.queue_items || []).forEach((item) => { + register(item.execution_id, item.run_id); + }); + register( + themeWorkbenchBackendRunState.latest_terminal?.execution_id, + themeWorkbenchBackendRunState.latest_terminal?.run_id, + ); + + return map; + }, [isThemeWorkbench, themeWorkbenchBackendRunState]); + + const themeWorkbenchBackendActivityLogs = useMemo< + SidebarActivityLog[] + >(() => { + if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { + return []; + } + + const runningLogs = (themeWorkbenchBackendRunState.queue_items || []).map( + (item) => { + const gateKey = + item.gate_key || inferThemeWorkbenchGateFromQueueItem(item).key; + return { + id: `run-queue-${item.run_id}`, + name: item.title || "执行主题工作台编排", + status: "running" as const, + timeLabel: formatThemeWorkbenchRunTimeLabel(item.started_at), + applyTarget: resolveThemeWorkbenchApplyTargetByGateKey(gateKey), + runId: item.run_id, + executionId: item.execution_id || undefined, + sessionId: item.session_id || undefined, + artifactPaths: + Array.isArray(item.artifact_paths) && item.artifact_paths.length > 0 + ? item.artifact_paths + : undefined, + gateKey, + source: item.source, + }; + }, + ); + + const terminal = themeWorkbenchBackendRunState.latest_terminal; + const terminalLog: SidebarActivityLog[] = terminal + ? [ + { + id: `run-terminal-${terminal.run_id}`, + name: terminal.title || "执行主题工作台编排", + status: terminal.status === "success" ? "completed" : "failed", + timeLabel: formatThemeWorkbenchRunTimeLabel( + terminal.finished_at || terminal.started_at, + ), + durationLabel: formatThemeWorkbenchRunDurationLabel( + terminal.started_at, + terminal.finished_at, + ), + applyTarget: resolveThemeWorkbenchApplyTargetByGateKey( + terminal.gate_key || "idle", + ), + runId: terminal.run_id, + executionId: terminal.execution_id || undefined, + sessionId: terminal.session_id || undefined, + artifactPaths: + Array.isArray(terminal.artifact_paths) && + terminal.artifact_paths.length > 0 + ? terminal.artifact_paths + : undefined, + gateKey: terminal.gate_key || "idle", + source: terminal.source, + }, + ] + : []; + + return [...runningLogs, ...terminalLog]; + }, [isThemeWorkbench, themeWorkbenchBackendRunState]); + + const themeWorkbenchActivityLogs = useMemo(() => { + if (!isThemeWorkbench) { + return contextWorkspace.activityLogs; + } + const enrichedContextLogs = contextWorkspace.activityLogs.map((log) => { + const normalizedRunId = log.runId?.trim(); + if (normalizedRunId) { + return { + ...log, + runId: normalizedRunId, + }; + } + + const candidateExecutionIds = + resolveExecutionIdCandidatesForActivityLog(log); + for (const executionId of candidateExecutionIds) { + const mappedRunId = themeWorkbenchExecutionRunMap.get(executionId); + if (!mappedRunId) { + continue; + } + return { + ...log, + executionId, + runId: mappedRunId, + }; + } + + return log; + }); + + return [...themeWorkbenchBackendActivityLogs, ...enrichedContextLogs]; + }, [ + contextWorkspace.activityLogs, + isThemeWorkbench, + themeWorkbenchBackendActivityLogs, + themeWorkbenchExecutionRunMap, + ]); + + const handleViewThemeWorkbenchRunDetail = useCallback((runId: string) => { + const normalizedRunId = runId.trim(); + if (!normalizedRunId) { + return; + } + setSelectedThemeWorkbenchRunId(normalizedRunId); + }, []); + + const handleViewContextDetail = useCallback( + (contextId: string) => { + const detail = contextWorkspace.getContextDetail(contextId); + if (!detail) { + toast.error("无法找到上下文详情"); + return; + } + + // 显示上下文详情 + const sourceLabel = + detail.source === "material" + ? "素材库" + : detail.source === "content" + ? "历史内容" + : "搜索结果"; + + toast.info( +
+
+ {detail.name} +
+
+ 来源: {sourceLabel} · 约 {detail.estimatedTokens} tokens +
+
+ {detail.bodyText || detail.previewText} +
+
, + { duration: 10000 }, + ); + }, + [contextWorkspace], + ); + + + useEffect(() => { + if (!isThemeWorkbench || !selectedThemeWorkbenchRunId) { + setThemeWorkbenchRunDetailLoading(false); + setSelectedThemeWorkbenchRunDetail(null); + return; + } + + let cancelled = false; + setThemeWorkbenchRunDetailLoading(true); + executionRunGet(selectedThemeWorkbenchRunId) + .then((detail) => { + if (!cancelled) { + setSelectedThemeWorkbenchRunDetail(detail); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + setSelectedThemeWorkbenchRunDetail(null); + console.warn("[AgentChatPage] 加载运行详情失败:", error); + }) + .finally(() => { + if (!cancelled) { + setThemeWorkbenchRunDetailLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isThemeWorkbench, selectedThemeWorkbenchRunId]); + + const currentGateBase = useMemo(() => { + if (!isThemeWorkbench) { + return { + key: "idle", + title: "编排待启动", + requiresUserDecision: false, + description: "输入目标后将自动进入编排执行。", + }; + } + + if (pendingActionRequest) { + const prompt = + pendingActionRequest.prompt || + pendingActionRequest.questions?.[0]?.question || + "等待你的决策以继续执行后续节点。"; + return { + key: pendingActionRequest.actionType, + title: "人工闸门", + requiresUserDecision: true, + description: prompt, + }; + } + + if (themeWorkbenchBackendRunState?.run_state === "auto_running") { + const backendGateKey = themeWorkbenchBackendRunState.current_gate_key; + if ( + backendGateKey === "topic_select" || + backendGateKey === "write_mode" || + backendGateKey === "publish_confirm" + ) { + const backendGate = resolveThemeWorkbenchGateByKey( + backendGateKey, + themeWorkbenchActiveQueueItem?.title, + ); + return { + key: backendGate.key, + title: backendGate.title, + requiresUserDecision: false, + description: backendGate.description, + }; + } + const backendGate = inferThemeWorkbenchGateFromQueueItem( + themeWorkbenchActiveQueueItem, + ); + return { + key: backendGate.key, + title: backendGate.title, + requiresUserDecision: false, + description: backendGate.description, + }; + } + + return { + key: "idle", + title: "编排待启动", + requiresUserDecision: false, + description: "输入目标后将自动进入编排执行。", + }; + }, [ + isThemeWorkbench, + pendingActionRequest, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState?.current_gate_key, + themeWorkbenchBackendRunState?.run_state, + ]); + + const themeWorkbenchRunState = useMemo< + "idle" | "auto_running" | "await_user_decision" + >(() => { + if (!isThemeWorkbench) { + return "idle"; + } + if (currentGateBase.requiresUserDecision) { + return "await_user_decision"; + } + if (themeWorkbenchBackendRunState) { + if (themeWorkbenchBackendRunState.run_state !== "auto_running") { + return "idle"; + } + + const hasFreshRunningQueueItem = ( + themeWorkbenchBackendRunState.queue_items || [] + ).some((item) => { + if (item.status !== "running") { + return false; + } + const startedAt = new Date(item.started_at); + if (Number.isNaN(startedAt.getTime())) { + return false; + } + return ( + Date.now() - startedAt.getTime() <= + THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS + ); + }); + + if (hasFreshRunningQueueItem || isSending) { + return "auto_running"; + } + return "idle"; + } + return isSending ? "auto_running" : "idle"; + }, [ + currentGateBase.requiresUserDecision, + isThemeWorkbench, + themeWorkbenchBackendRunState, + isSending, + ]); + + const currentGate = useMemo(() => { + const status = currentGateBase.requiresUserDecision + ? ("waiting" as const) + : themeWorkbenchRunState === "auto_running" + ? ("running" as const) + : ("idle" as const); + + return { + key: currentGateBase.key, + title: currentGateBase.title, + description: currentGateBase.description, + status, + }; + }, [currentGateBase, themeWorkbenchRunState]); + + useEffect(() => { + if (!isThemeWorkbench || themeWorkbenchRunState !== "idle") { + return; + } + if (!canvasState || canvasState.type !== "document") { + return; + } + + setDocumentVersionStatusMap((previous) => { + const latestTerminal = themeWorkbenchBackendRunState?.latest_terminal; + if (latestTerminal) { + const terminalVersionId = latestTerminal.run_id; + const terminalVersionExists = canvasState.versions.some( + (version) => version.id === terminalVersionId, + ); + if (terminalVersionExists) { + const terminalStatus: TopicBranchStatus = + latestTerminal.status === "success" ? "merged" : "candidate"; + if (previous[terminalVersionId] !== terminalStatus) { + return { + ...previous, + [terminalVersionId]: terminalStatus, + }; + } + } + } + + const currentVersionId = canvasState.currentVersionId; + if (!currentVersionId || previous[currentVersionId] !== "in_progress") { + return previous; + } + return { + ...previous, + [currentVersionId]: "pending", + }; + }); + }, [ + canvasState, + isThemeWorkbench, + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchRunState, + ]); + // 会话文件持久化 hook const { saveFile: saveSessionFile, @@ -620,72 +2883,34 @@ export function AgentChatPage({ // 监听画布状态变化,自动同步到 Content useEffect(() => { - if (!canvasState) return; + if (!canvasState || !contentId) { + return; + } - // 提取画布内容 - let content = ""; try { - switch (canvasState.type) { - case "document": - content = canvasState.content || ""; - break; - case "novel": - content = JSON.stringify(canvasState.chapters); - break; - case "script": - content = JSON.stringify(canvasState.scenes); - break; - case "music": - content = JSON.stringify(canvasState.sections); - break; - case "poster": - content = JSON.stringify(canvasState.pages); - break; - default: - content = JSON.stringify(canvasState); + const content = serializeCanvasStateForSync(canvasState); + if (isSyncContentEmpty(content)) { + return; } - // 如果有 contentId,先验证内容存在再同步 - if (contentId && content) { - // 先检查内容是否存在,避免同步到不存在的记录 - getContent(contentId) - .then((existingContent) => { - if (existingContent) { - const existingBody = existingContent.body || ""; - if (existingBody !== content) { - syncContent(contentId, content); - } - } else { - console.warn( - "[AgentChatPage] contentId 对应的内容不存在,跳过同步:", - contentId, - ); - } - }) - .catch((err) => { - console.error("[AgentChatPage] 检查内容存在性失败:", err); - }); - } - // 如果没有 contentId 但有 projectId,自动创建 Content - else if (!contentId && projectId && content && project) { - // 只在内容不为空时创建 - const isEmpty = - !content || content === "" || content === "[]" || content === "{}"; - if (!isEmpty) { - console.log("[AgentChatPage] 自动创建 Content 记录"); - // TODO: 实现自动创建 Content 的逻辑 - // 这里需要调用 createContent API,但为了避免重复创建,需要添加防抖和状态管理 - } + const previousRequest = lastCanvasSyncRequestRef.current; + if ( + previousRequest?.contentId === contentId && + previousRequest.body === content + ) { + return; } + + lastCanvasSyncRequestRef.current = { contentId, body: content }; + syncContent(contentId, content); } catch (error) { console.error("提取画布内容失败:", error); } - }, [canvasState, contentId, projectId, project, syncContent]); + }, [canvasState, contentId, syncContent]); // 追踪已恢复元数据和文件的会话 ID const restoredMetaSessionId = useRef(null); const restoredFilesSessionId = useRef(null); - const handledNewChatRequestRef = useRef(null); // 用于追踪是否已触发过 AI 引导 const hasTriggeredGuide = useRef(false); @@ -757,7 +2982,11 @@ export function AgentChatPage({ restoredFiles.push({ id: crypto.randomUUID(), name: file.name, - type: file.fileType === "document" ? "document" : "document", + type: normalizeSessionTaskFileType( + file.fileType, + file.name, + content, + ), content, version: 1, createdAt: file.createdAt, @@ -895,9 +3124,9 @@ export function AgentChatPage({ /** * 从 AI 响应中提取文档内容 * 支持多种格式: - * 1. ... 标签 + * 1. ... 标签(推荐) * 2. ```markdown ... ``` 代码块 - * 3. 以 # 开头的 Markdown 内容(整个响应) + * 3. 以 # 开头的 Markdown 内容(仅非主题工作台) */ const extractDocumentContent = useCallback( (content: string): string | null => { @@ -913,14 +3142,19 @@ export function AgentChatPage({ return markdownMatch[1].trim(); } - // 3. 如果整个内容以 # 开头且长度超过 200 字符,认为是文档 + // 3. 主题工作台:不使用启发式规则,避免误判普通回复 + if (isThemeWorkbench) { + return null; + } + + // 4. 非主题工作台:如果整个内容以 # 开头且长度超过 200 字符,认为是文档 if (content.trim().startsWith("#") && content.length > 200) { return content.trim(); } return null; }, - [], + [isThemeWorkbench], ); const looksLikeSerializedNovelState = useCallback((content: string) => { @@ -988,11 +3222,25 @@ export function AgentChatPage({ const lastAssistantMsg = [...messages] .reverse() .find( - (msg) => msg.role === "assistant" && !msg.isThinking && msg.content, + (msg) => + msg.role === "assistant" && + !msg.isThinking && + msg.content && + msg.purpose !== "content_review", ); if (!lastAssistantMsg) return; + // 主题工作台 fallback:仅在 AI 未使用 write_file 且画布为空时提取 + if (isThemeWorkbench) { + const hasWriteFileToolCall = lastAssistantMsg.toolCalls?.some((tc) => { + const name = (tc.name || "").toLowerCase(); + return name.includes("write") || name.includes("create_file"); + }); + if (hasWriteFileToolCall) return; + if (canvasState && !isCanvasStateEmpty(canvasState)) return; + } + // 检查是否已处理过 if (processedMessageIds.current.has(lastAssistantMsg.id)) return; @@ -1037,9 +3285,11 @@ export function AgentChatPage({ }, [ messages, isContentCreationMode, + isThemeWorkbench, extractDocumentContent, mappedTheme, upsertNovelCanvasState, + canvasState, ]); const handleSend = useCallback( @@ -1049,19 +3299,51 @@ export function AgentChatPage({ thinking?: boolean, textOverride?: string, sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + autoContinuePayload?: AutoContinueRequestPayload, + sendOptions?: HandleSendOptions, ) => { - const sourceText = textOverride ?? input; + let sourceText = textOverride ?? input; if (!sourceText.trim() && (!images || images.length === 0)) return; const effectiveWebSearch = webSearch ?? chatToolPreferences.webSearch; const effectiveThinking = thinking ?? chatToolPreferences.thinking; if (!projectId) { + sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); toast.error("请先选择项目后再开始对话"); return; } + if ( + isThemeWorkbench && + mappedTheme === "social-media" && + sourceText.trim() && + !sourceText.trimStart().startsWith("/") && + !sendOptions?.skipThemeSkillPrefix + ) { + sourceText = `/${SOCIAL_ARTICLE_SKILL_KEY} ${sourceText}`.trim(); + } + let text = sourceText; + const preparedActiveContextPrompt = contextWorkspace.enabled + ? await contextWorkspace.prepareActiveContextPrompt() + : ""; + + if (contextWorkspace.enabled && preparedActiveContextPrompt) { + const slashCommandMatch = text.match( + /^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/, + ); + if (slashCommandMatch) { + const [, skillName, skillArgs] = slashCommandMatch; + const mergedArgs = [preparedActiveContextPrompt, skillArgs.trim()] + .filter((part) => part.length > 0) + .join("\n\n"); + text = `/${skillName} ${mergedArgs}`.trim(); + } else { + text = `${preparedActiveContextPrompt}\n\n${text}`; + } + } + // 如果有引用的角色,注入角色信息 if (mentionedCharacters.length > 0) { const characterContext = mentionedCharacters @@ -1113,7 +3395,9 @@ export function AgentChatPage({ const warnKey = `${providerType}:${model}`; if (!thinkingVariantWarnedRef.current.has(warnKey)) { thinkingVariantWarnedRef.current.add(warnKey); - toast.warning("当前 Provider 没有可用的 Thinking 模型,已保持原模型"); + toast.warning( + "当前 Provider 没有可用的 Thinking 模型,已保持原模型", + ); } } } else { @@ -1129,26 +3413,48 @@ export function AgentChatPage({ } } - await sendMessage( - text, - images || [], - effectiveWebSearch, - effectiveThinking, - false, - sendExecutionStrategy, - effectiveModel, - ); + if (autoContinuePayload) { + await sendMessage( + text, + images || [], + effectiveWebSearch, + effectiveThinking, + false, + sendExecutionStrategy, + effectiveModel, + autoContinuePayload, + sendOptions, + ); + } else { + await sendMessage( + text, + images || [], + effectiveWebSearch, + effectiveThinking, + false, + sendExecutionStrategy, + effectiveModel, + undefined, + sendOptions, + ); + } } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + sendOptions?.observer?.onError?.(errorMessage); console.error("[AgentChat] 发送消息失败:", error); - toast.error(`发送失败: ${error instanceof Error ? error.message : String(error)}`); + toast.error(`发送失败: ${errorMessage}`); // 恢复输入内容,让用户可以重试 setInput(sourceText); } }, [ chatToolPreferences, + contextWorkspace, input, + isThemeWorkbench, mentionedCharacters, + mappedTheme, model, projectId, providerModels, @@ -1159,6 +3465,127 @@ export function AgentChatPage({ ], ); + const handleSendRef = useRef(handleSend); + const webSearchPreferenceRef = useRef(chatToolPreferences.webSearch); + + useEffect(() => { + handleSendRef.current = handleSend; + }, [handleSend]); + + useEffect(() => { + webSearchPreferenceRef.current = chatToolPreferences.webSearch; + }, [chatToolPreferences.webSearch]); + + const handleDocumentThinkingEnabledChange = useCallback( + (enabled: boolean) => { + setChatToolPreferences((previous) => + previous.thinking === enabled + ? previous + : { + ...previous, + thinking: enabled, + }, + ); + }, + [], + ); + + const handleDocumentAutoContinueRun = useCallback( + async (payload: AutoContinueRunPayload) => { + await handleSendRef.current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + { + enabled: payload.settings.enabled, + fast_mode_enabled: payload.settings.fastModeEnabled, + continuation_length: payload.settings.continuationLength, + sensitivity: payload.settings.sensitivity, + source: "theme_workbench_document_auto_continue", + }, + ); + }, + [], + ); + + const handleDocumentContentReviewRun = useCallback( + async (payload: ContentReviewRunPayload) => { + return await new Promise((resolve, reject) => { + void handleSendRef + .current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "content_review", + observer: { + onComplete: resolve, + onError: (message) => reject(new Error(message)), + }, + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + }, + [], + ); + + const handleDocumentTextStylizeRun = useCallback( + async (payload: TextStylizeRunPayload) => { + return await new Promise((resolve, reject) => { + void handleSendRef + .current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "text_stylize", + observer: { + onComplete: resolve, + onError: (message) => reject(new Error(message)), + }, + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + }, + [], + ); + + // 监听主题工作台技能触发 + useEffect(() => { + if (!pendingSkillKey || !isThemeWorkbench) { + return; + } + + // 立即消费,避免重复触发 + consumePendingSkill(); + + // 触发技能命令 + const command = `/${pendingSkillKey}`; + console.log("[AgentChatPage] 执行技能命令:", command); + handleSend([], false, false, command); + }, [ + pendingSkillKey, + isThemeWorkbench, + consumePendingSkill, + handleSend, + ]); + const handleClearMessages = useCallback(() => { clearMessages(); setInput(""); @@ -1177,6 +3604,175 @@ export function AgentChatPage({ isResolvingTopicProjectRef.current = false; }, [clearMessages]); + const handleSwitchBranchVersion = useCallback( + (versionId: string) => { + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + return previous; + } + + const targetVersion = previous.versions.find( + (version) => version.id === versionId, + ); + if (!targetVersion) { + return previous; + } + + return { + ...previous, + currentVersionId: targetVersion.id, + content: targetVersion.content, + }; + }); + }, + [setCanvasState], + ); + + const handleCreateVersionSnapshot = useCallback(() => { + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.info("当前没有可管理的文稿版本"); + return previous; + } + + const content = previous.content.trim(); + if (!content) { + toast.info("主稿为空,无法创建版本快照"); + return previous; + } + + const nextIndex = previous.versions.length + 1; + const newVersion = { + id: crypto.randomUUID(), + content: previous.content, + createdAt: Date.now(), + description: `手动快照 - 版本 ${nextIndex}`, + }; + + toast.success("已创建版本快照"); + return { + ...previous, + versions: [...previous.versions, newVersion], + currentVersionId: newVersion.id, + }; + }); + }, [setCanvasState]); + + const handleSetBranchStatus = useCallback( + ( + topicId: string, + status: "in_progress" | "pending" | "merged" | "candidate", + ) => { + setTopicStatus(topicId, status); + if (status === "merged") { + toast.success("已将该版本标记为主稿"); + } else if (status === "pending") { + toast.info("已将该版本标记为待评审"); + } + }, + [setTopicStatus], + ); + + const handleAddImage = useCallback( + async () => { + try { + const selected = await openDialog({ + multiple: false, + filters: [ + { + name: "图片", + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (!selected) { + return; + } + + const filePath = typeof selected === "string" ? selected : selected.path; + if (!filePath) { + toast.error("未选择文件"); + return; + } + + toast.info("正在上传图片..."); + + // 上传图片到会话 + const imageUrl = await uploadImageToSession(sessionId, filePath); + + // 插入图片到文档 + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.error("当前不在文档编辑模式"); + return previous; + } + + const fileName = filePath.split(/[\\/]/).pop() || "image"; + const imageMarkdown = `\n\n![${fileName}](${imageUrl})\n\n`; + + return { + ...previous, + content: previous.content + imageMarkdown, + }; + }); + + toast.success("图片已添加"); + } catch (error) { + console.error("添加图片失败:", error); + toast.error(error instanceof Error ? error.message : "添加图片失败"); + } + }, + [sessionId, setCanvasState], + ); + + const handleImportDocument = useCallback(async () => { + try { + const selected = await openDialog({ + multiple: false, + filters: [ + { + name: "文档", + extensions: ["md", "txt"], + }, + ], + }); + + if (!selected) { + return; + } + + const filePath = typeof selected === "string" ? selected : selected.path; + if (!filePath) { + toast.error("未选择文件"); + return; + } + + toast.info("正在导入文稿..."); + + // 调用后端解析接口 + const content = await importDocument(filePath); + + // 加载到文档 + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.error("当前不在文档编辑模式"); + return previous; + } + + return { + ...previous, + content: content, + }; + }); + + toast.success("文稿已导入"); + } catch (error) { + console.error("导入文稿失败:", error); + toast.error(error instanceof Error ? error.message : "导入文稿失败"); + } + }, [setCanvasState]); + // 响应首页导航触发的新会话请求 useEffect(() => { if (!newChatAt) { @@ -1251,8 +3847,13 @@ export function AgentChatPage({ const handleCanvasSelectionTextChange = useCallback((text: string) => { const normalized = text.trim().replace(/\s+/g, " "); - const nextValue = normalized.length > 500 ? normalized.slice(0, 500) : normalized; - setSelectedText((previous) => (previous === nextValue ? previous : nextValue)); + const nextValue = + normalized.length > 500 ? normalized.slice(0, 500) : normalized; + startTransition(() => { + setSelectedText((previous) => + previous === nextValue ? previous : nextValue, + ); + }); }, []); useEffect(() => { @@ -1308,68 +3909,82 @@ export function AgentChatPage({ onHasMessagesChange?.(hasMessages); }, [hasMessages, onHasMessagesChange]); - // 当有文件时默认在画布中显示最新文件(按更新时间) + // 当有可渲染主稿文件时,仅在需要时同步到画布,避免打断当前编辑 useEffect(() => { - if (taskFiles.length > 0) { - const latestFile = taskFiles.reduce( - (candidate, file) => { - if (!candidate) { - return file; - } - const candidateTimestamp = Math.max( - candidate.updatedAt, - candidate.createdAt, - ); - const fileTimestamp = Math.max(file.updatedAt, file.createdAt); - return fileTimestamp >= candidateTimestamp ? file : candidate; - }, - null, - ); - - if (!latestFile) { - return; - } - - // 设置选中的文件 - setSelectedFileId(latestFile.id); - // 如果文件有内容,在画布中显示 - const latestContent = latestFile.content; - if (latestContent) { - setCanvasState((prev) => { - if (mappedTheme === "music") { - const sections = parseLyrics(latestContent); - if (!prev || prev.type !== "music") { - const musicState = createInitialMusicState(); - musicState.sections = sections; - const titleMatch = latestContent.match(/^#\s*(.+)$/m); - if (titleMatch) { - musicState.spec.title = titleMatch[1].trim(); - } - return musicState; - } - return { ...prev, sections }; - } - - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, latestContent); - } - - if (!prev || prev.type !== "document") { - return createInitialDocumentState(latestContent); - } - return { ...prev, content: latestContent }; - }); - setLayoutMode("chat-canvas"); - } + const renderableFiles = taskFiles.filter((file) => + isRenderableTaskFile(file, isThemeWorkbench), + ); + if (renderableFiles.length === 0) { + return; } - }, [taskFiles, mappedTheme, upsertNovelCanvasState]); - const handleToggleSidebar = () => { + const { targetFile, nextSelectedFileId } = resolveCanvasTaskFileTarget( + renderableFiles, + selectedFileId, + ); + if (!targetFile?.content) { + return; + } + + if (nextSelectedFileId) { + setSelectedFileId((previous) => + previous === nextSelectedFileId ? previous : nextSelectedFileId, + ); + } + + if ( + shouldDeferCanvasSyncWhileEditing({ + canvasType: canvasState?.type ?? null, + editorFocused: documentEditorFocusedRef.current, + }) + ) { + return; + } + + const targetContent = targetFile.content; + setCanvasState((prev) => { + if (mappedTheme === "music") { + const sections = parseLyrics(targetContent); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = targetContent.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...prev, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, targetContent); + } + + if (!prev || prev.type !== "document") { + return createInitialDocumentState(targetContent); + } + if (prev.content === targetContent) { + return prev; + } + return { ...prev, content: targetContent }; + }); + setLayoutMode("chat-canvas"); + }, [ + taskFiles, + isThemeWorkbench, + mappedTheme, + upsertNovelCanvasState, + selectedFileId, + canvasState?.type, + ]); + + const handleToggleSidebar = useCallback(() => { if (!showChatPanel) { return; } setShowSidebar((prev) => !prev); - }; + }, [showChatPanel]); const handleToggleNovelChapterList = useCallback(() => { setNovelChapterListCollapsed((prev) => !prev); @@ -1447,8 +4062,37 @@ export function AgentChatPage({ } }, [activeTheme]); + const resolvedCanvasState = useMemo(() => { + if (canvasState) { + return canvasState; + } + + if (shouldBootstrapCanvasOnEntry) { + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + } + + if (isThemeWorkbench && isContentCreationTheme(activeTheme)) { + return ( + createInitialCanvasState(mappedTheme, "") || + createInitialDocumentState("") + ); + } + + return null; + }, [ + activeTheme, + canvasState, + isThemeWorkbench, + mappedTheme, + normalizedEntryTheme, + shouldBootstrapCanvasOnEntry, + ]); + const showNovelNavbarControls = - layoutMode !== "chat" && canvasState?.type === "novel"; + layoutMode !== "chat" && resolvedCanvasState?.type === "novel"; // 处理文件写入 - 同名文件更新内容,不同名文件独立保存 const handleWriteFile = useCallback( @@ -1509,6 +4153,36 @@ export function AgentChatPage({ } const now = Date.now(); + const nextFileType = resolveTaskFileType(fileName, content); + const activeQueueItem = themeWorkbenchActiveQueueItem; + const activeRunVersionId = activeQueueItem?.run_id?.trim() || null; + const activeRunDescription = + activeQueueItem?.title?.trim() || `产物更新 - ${fileName}`; + const isThemeWorkbenchPrimaryArtifact = + !isThemeWorkbench || isThemeWorkbenchPrimaryDocumentArtifact(fileName); + const shouldApplyToMainDocument = + nextFileType === "document" && + isThemeWorkbenchPrimaryArtifact && + (!isThemeWorkbench || currentGate.key !== "topic_select"); + const effectiveThemeWorkbenchVersionId = + activeRunVersionId || + (isThemeWorkbench && shouldApplyToMainDocument + ? `artifact:${fileName}` + : null); + + if (isThemeWorkbench && effectiveThemeWorkbenchVersionId) { + const nextStatus: TopicBranchStatus = + activeQueueItem?.status === "running" ? "in_progress" : "pending"; + setDocumentVersionStatusMap((previous) => { + if (previous[effectiveThemeWorkbenchVersionId] === nextStatus) { + return previous; + } + return { + ...previous, + [effectiveThemeWorkbenchVersionId]: nextStatus, + }; + }); + } // 持久化文件到会话目录 saveSessionFile(fileName, content).catch((err) => { @@ -1516,7 +4190,7 @@ export function AgentChatPage({ }); // 同步内容到项目(如果有 contentId,先验证存在性) - if (contentId) { + if (contentId && shouldApplyToMainDocument) { getContent(contentId) .then((existingContent) => { if (existingContent) { @@ -1535,6 +4209,12 @@ export function AgentChatPage({ .catch((err) => { console.error("[AgentChatPage] 检查内容存在性失败:", err); }); + } else if (isThemeWorkbench && !shouldApplyToMainDocument) { + console.log("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", { + gate: currentGate.key, + fileName, + isPrimaryArtifact: isThemeWorkbenchPrimaryArtifact, + }); } // 根据文件名推进工作流步骤(使用动态映射) @@ -1577,6 +4257,7 @@ export function AgentChatPage({ const updated = [...prev]; updated[existingIndex] = { ...existing, + type: nextFileType, content, updatedAt: now, }; @@ -1589,7 +4270,7 @@ export function AgentChatPage({ const newFile: TaskFile = { id: crypto.randomUUID(), name: fileName, - type: "document", + type: nextFileType, content, version: 1, createdAt: now, @@ -1599,6 +4280,10 @@ export function AgentChatPage({ return [...prev, newFile]; }); + if (!shouldApplyToMainDocument) { + return; + } + // 更新画布内容 setCanvasState((prev) => { console.log("[AgentChatPage] 更新画布状态:", { @@ -1640,7 +4325,62 @@ export function AgentChatPage({ // 文档类型画布 if (!prev || prev.type !== "document") { console.log("[AgentChatPage] 创建新文档状态"); - return createInitialDocumentState(content); + const initialDocumentState = createInitialDocumentState(content); + if (!isThemeWorkbench || !effectiveThemeWorkbenchVersionId) { + return initialDocumentState; + } + return { + ...initialDocumentState, + versions: [ + { + id: effectiveThemeWorkbenchVersionId, + content, + createdAt: now, + description: activeRunDescription, + }, + ], + currentVersionId: effectiveThemeWorkbenchVersionId, + content, + }; + } + + if (isThemeWorkbench && effectiveThemeWorkbenchVersionId) { + const existingIndex = prev.versions.findIndex( + (version) => version.id === effectiveThemeWorkbenchVersionId, + ); + + if (existingIndex >= 0) { + const nextVersions = [...prev.versions]; + const currentVersion = nextVersions[existingIndex]; + nextVersions[existingIndex] = { + ...currentVersion, + content, + description: currentVersion.description || activeRunDescription, + }; + return { + ...prev, + content, + versions: nextVersions, + currentVersionId: effectiveThemeWorkbenchVersionId, + }; + } + + const nextVersions = [ + ...prev.versions, + { + id: effectiveThemeWorkbenchVersionId, + content, + createdAt: now, + description: activeRunDescription, + }, + ].slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + + return { + ...prev, + content, + versions: nextVersions, + currentVersionId: effectiveThemeWorkbenchVersionId, + }; } console.log("[AgentChatPage] 更新现有文档状态"); return { @@ -1654,12 +4394,15 @@ export function AgentChatPage({ }, [ activeTheme, // 添加 activeTheme 依赖 + currentGate.key, contentId, currentStepIndex, isContentCreationMode, + isThemeWorkbench, completeStep, mappedTheme, saveSessionFile, + themeWorkbenchActiveQueueItem, upsertNovelCanvasState, ], ); @@ -1716,6 +4459,7 @@ export function AgentChatPage({ } // 查找或创建任务文件 + const nextFileType = resolveTaskFileType(fileName, content); setTaskFiles((prev) => { const existingFile = prev.find((f) => f.name === fileName); if (existingFile) { @@ -1726,7 +4470,7 @@ export function AgentChatPage({ const newFile: TaskFile = { id: crypto.randomUUID(), name: fileName, - type: "document", + type: nextFileType, content, version: 1, createdAt: Date.now(), @@ -1736,6 +4480,16 @@ export function AgentChatPage({ return [...prev, newFile]; }); + if ( + !isRenderableTaskFile( + { name: fileName, type: nextFileType }, + isThemeWorkbench, + ) + ) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; + } + // 更新画布内容 setCanvasState((prev) => { // 音乐主题:解析歌词并更新 sections @@ -1770,7 +4524,7 @@ export function AgentChatPage({ // 打开画布 setLayoutMode("chat-canvas"); }, - [activeTheme, mappedTheme, upsertNovelCanvasState], + [activeTheme, isThemeWorkbench, mappedTheme, upsertNovelCanvasState], ); // 处理代码块点击 - 在画布中显示代码(General 主题专用) @@ -1816,42 +4570,50 @@ export function AgentChatPage({ // 处理任务文件点击 - 在画布中显示文件内容 const handleTaskFileClick = useCallback( (file: TaskFile) => { - if (file.type === "document" && file.content) { - setSelectedFileId(file.id); - setCanvasState((prev) => { - // 音乐主题:解析歌词并更新 sections - if (mappedTheme === "music") { - const sections = parseLyrics(file.content!); - if (!prev || prev.type !== "music") { - const musicState = createInitialMusicState(); - musicState.sections = sections; - const titleMatch = file.content!.match(/^#\s*(.+)$/m); - if (titleMatch) { - musicState.spec.title = titleMatch[1].trim(); - } - return musicState; - } - return { ...prev, sections }; - } + setSelectedFileId(file.id); - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, file.content!); - } - - // 文档类型画布 - if (!prev || prev.type !== "document") { - return createInitialDocumentState(file.content!); - } - return { - ...prev, - content: file.content!, - }; - }); - // 只打开画布,不关闭文件列表(让用户自己关闭) - setLayoutMode("chat-canvas"); + if ( + !isRenderableTaskFile(file, isThemeWorkbench) || + looksLikeSocialPublishPayload(file.content || "") || + !file.content?.trim() + ) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; } + + setCanvasState((prev) => { + // 音乐主题:解析歌词并更新 sections + if (mappedTheme === "music") { + const sections = parseLyrics(file.content); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = file.content.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...prev, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, file.content); + } + + // 文档类型画布 + if (!prev || prev.type !== "document") { + return createInitialDocumentState(file.content); + } + return { + ...prev, + content: file.content, + }; + }); + // 只打开画布,不关闭文件列表(让用户自己关闭) + setLayoutMode("chat-canvas"); }, - [mappedTheme, upsertNovelCanvasState], + [isThemeWorkbench, mappedTheme, upsertNovelCanvasState], ); // A2UI 表单提交处理 @@ -1903,9 +4665,8 @@ export function AgentChatPage({ canvasEmpty && !hasTriggeredGuide.current ) { - hasTriggeredGuide.current = true; - if (pendingInitialPrompt) { + hasTriggeredGuide.current = true; console.log("[AgentChatPage] 自动发送首条创作意图消息"); void (async () => { await handleSend( @@ -1919,6 +4680,18 @@ export function AgentChatPage({ return; } + if (isThemeWorkbench) { + hasTriggeredGuide.current = true; + const themeGuide = getDefaultGuidePromptByTheme(activeTheme); + if (themeGuide) { + console.log("[AgentChatPage] 主题工作台:预填引导词"); + setInput((prev) => (prev.trim() ? prev : themeGuide)); + } + // 不自动发送,让用户确认后手动发送 + return; + } + + hasTriggeredGuide.current = true; const defaultGuidePrompt = getDefaultGuidePromptByTheme(activeTheme); if (defaultGuidePrompt) { console.log("[AgentChatPage] 自动预填主题引导词"); @@ -1943,6 +4716,7 @@ export function AgentChatPage({ isSending, canvasState, initialUserPrompt, + isThemeWorkbench, handleSend, chatToolPreferences, onInitialUserPromptConsumed, @@ -1953,8 +4727,99 @@ export function AgentChatPage({ hasTriggeredGuide.current = false; }, [contentId]); - // 判断是否应该显示聊天布局(有消息) - const showChatLayout = hasMessages; + // 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程 + const showChatLayout = hasMessages || isThemeWorkbench; + const showThemeWorkbenchSidebar = + showChatPanel && + showSidebar && + isThemeWorkbench && + (!enableThemeWorkbenchPanelCollapse || !themeWorkbenchSidebarCollapsed); + const showThemeWorkbenchLeftExpandButton = + showChatPanel && + showSidebar && + enableThemeWorkbenchPanelCollapse && + themeWorkbenchSidebarCollapsed; + const handleThemeWorkbenchDeleteTopic = useCallback(() => {}, []); + const handleThemeWorkbenchSidebarCollapse = useCallback(() => { + setThemeWorkbenchSidebarCollapsed(true); + }, []); + const themeWorkbenchSidebarCollapseHandler = useMemo( + () => + enableThemeWorkbenchPanelCollapse + ? handleThemeWorkbenchSidebarCollapse + : undefined, + [enableThemeWorkbenchPanelCollapse, handleThemeWorkbenchSidebarCollapse], + ); + const themeWorkbenchSidebarNode = useMemo(() => { + if (!showThemeWorkbenchSidebar) { + return null; + } + return ( + + ); + }, [ + branchItems, + contextWorkspace.addFileContext, + contextWorkspace.addLinkContext, + contextWorkspace.addTextContext, + contextWorkspace.contextBudget, + contextWorkspace.contextSearchBlockedReason, + contextWorkspace.contextSearchError, + contextWorkspace.contextSearchLoading, + contextWorkspace.contextSearchMode, + contextWorkspace.contextSearchQuery, + contextWorkspace.setContextSearchMode, + contextWorkspace.setContextSearchQuery, + contextWorkspace.sidebarContextItems, + contextWorkspace.submitContextSearch, + contextWorkspace.toggleContextActive, + handleAddImage, + handleImportDocument, + handleCreateVersionSnapshot, + handleSetBranchStatus, + handleSwitchBranchVersion, + handleThemeWorkbenchDeleteTopic, + handleViewContextDetail, + handleViewThemeWorkbenchRunDetail, + selectedThemeWorkbenchRunDetail, + showThemeWorkbenchSidebar, + themeWorkbenchCreationTaskEvents, + themeWorkbenchActivityLogs, + themeWorkbenchRunDetailLoading, + themeWorkbenchSidebarCollapseHandler, + themeWorkbenchWorkflowSteps, + ]); const workflowProgressSignature = useMemo(() => { const shouldShow = isContentCreationMode && hasMessages && steps.length > 0; @@ -2017,6 +4882,19 @@ export function AgentChatPage({ _onNavigate?.("resources"); }, [_onNavigate]); + const handleProjectChange = useCallback( + (newProjectId: string) => { + if (externalProjectId) { + return; + } + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + savePersistedProjectId(LAST_PROJECT_ID_KEY, newProjectId); + setInternalProjectId(newProjectId); + }, + [externalProjectId], + ); + const handleSelectWorkspaceDirectory = useCallback(async () => { const newPath = await openDialog({ directory: true, multiple: false }); if (!newPath) return; @@ -2040,170 +4918,312 @@ export function AgentChatPage({ } }, [fixWorkspacePathAndRetry, projectId, workspacePathMissing]); + const handleSelectCharacter = useCallback((character: Character) => { + setMentionedCharacters((prev) => { + if (prev.find((c) => c.id === character.id)) { + return prev; + } + return [...prev, character]; + }); + }, []); + + const handleToggleTaskFiles = useCallback(() => { + setTaskFilesExpanded((previous) => !previous); + }, []); + + const visibleTaskFiles = useMemo( + () => + taskFiles.filter((file) => isRenderableTaskFile(file, isThemeWorkbench)), + [taskFiles, isThemeWorkbench], + ); + + const visibleSelectedFileId = useMemo(() => { + if (!selectedFileId) { + return undefined; + } + return visibleTaskFiles.some((file) => file.id === selectedFileId) + ? selectedFileId + : undefined; + }, [selectedFileId, visibleTaskFiles]); + + const inputbarNode = useMemo( + () => ( + + ), + [ + activeTheme, + chatToolPreferences, + currentGate, + executionStrategy, + handleClearMessages, + handleManageProviders, + handleNavigateToSkillSettings, + handleSelectCharacter, + handleSend, + handleTaskFileClick, + handleToggleCanvas, + handleToggleTaskFiles, + input, + isSending, + isThemeWorkbench, + layoutMode, + model, + projectId, + projectMemory?.characters, + providerType, + setExecutionStrategy, + setInput, + setModel, + setProviderType, + skills, + steps, + stopSending, + visibleSelectedFileId, + visibleTaskFiles, + taskFilesExpanded, + themeWorkbenchRunState, + themeWorkbenchWorkflowSteps, + ], + ); + // 聊天区域内容 - const chatContent = ( - - - {/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */} - {!hideInlineStepProgress && - isContentCreationMode && - hasMessages && - steps.length > 0 && ( - - )} - - {showChatLayout ? ( - - - - ) : ( - { - handleSend( - images || [], - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - text, - sendExecutionStrategy, - ); - }} - providerType={providerType} - setProviderType={setProviderType} - model={model} - setModel={setModel} - executionStrategy={executionStrategy} - setExecutionStrategy={setExecutionStrategy} - onManageProviders={handleManageProviders} - webSearchEnabled={chatToolPreferences.webSearch} - onWebSearchEnabledChange={(enabled) => - setChatToolPreferences((prev) => ({ - ...prev, - webSearch: enabled, - })) - } - thinkingEnabled={chatToolPreferences.thinking} - onThinkingEnabledChange={(enabled) => - setChatToolPreferences((prev) => ({ - ...prev, - thinking: enabled, - })) - } - creationMode={creationMode} - onCreationModeChange={setCreationMode} - activeTheme={activeTheme} - onThemeChange={(theme) => { - if (!lockTheme) { - setActiveTheme(theme); - } - }} - showThemeTabs={false} - hasCanvasContent={ - activeTheme === "general" - ? Boolean(generalCanvasState.content?.trim()) - : !isCanvasStateEmpty(canvasState) - } - hasContentId={Boolean(contentId)} - selectedText={selectedText} - onRecommendationClick={(shortLabel, fullPrompt) => { - // 直接将推荐提示词放入输入框,不创建项目 - setInput(fullPrompt); - }} - characters={projectMemory?.characters || []} - skills={skills} - onNavigateToSettings={handleNavigateToSkillSettings} - /> - )} - - {showChatLayout && ( - <> - {(workspacePathMissing || workspaceHealthError) && ( -
- 工作区目录不存在,请重新选择一个本地目录后继续 - - -
+ const chatContent = useMemo( + () => ( + + + {!hideInlineStepProgress && + isContentCreationMode && + hasMessages && + steps.length > 0 && ( + )} - + {contextWorkspace.enabled ? ( + + + + ) : ( + + )} + {contextWorkspace.enabled && !isThemeWorkbench ? ( + + {inputbarNode} + + ) : null} + + ) : ( + { + handleSend( + images || [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + text, + sendExecutionStrategy, + ); + }} providerType={providerType} setProviderType={setProviderType} model={model} setModel={setModel} executionStrategy={executionStrategy} setExecutionStrategy={setExecutionStrategy} - activeTheme={activeTheme} onManageProviders={handleManageProviders} - disabled={!projectId} - onClearMessages={handleClearMessages} - onToggleCanvas={handleToggleCanvas} - isCanvasOpen={layoutMode !== "chat"} - taskFiles={taskFiles} - selectedFileId={selectedFileId} - taskFilesExpanded={taskFilesExpanded} - onToggleTaskFiles={() => setTaskFilesExpanded(!taskFilesExpanded)} - onTaskFileClick={handleTaskFileClick} + webSearchEnabled={chatToolPreferences.webSearch} + onWebSearchEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + webSearch: enabled, + })) + } + thinkingEnabled={chatToolPreferences.thinking} + onThinkingEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + thinking: enabled, + })) + } + creationMode={creationMode} + onCreationModeChange={setCreationMode} + activeTheme={activeTheme} + onThemeChange={(theme) => { + if (!lockTheme) { + setActiveTheme(theme); + } + }} + showThemeTabs={false} + hasCanvasContent={ + activeTheme === "general" + ? Boolean(generalCanvasState.content?.trim()) + : !isCanvasStateEmpty(resolvedCanvasState) + } + hasContentId={Boolean(contentId)} + selectedText={selectedText} + onRecommendationClick={(shortLabel, fullPrompt) => { + setInput(fullPrompt); + }} characters={projectMemory?.characters || []} skills={skills} - toolStates={chatToolPreferences} - onToolStatesChange={setChatToolPreferences} - onSelectCharacter={(character) => { - setMentionedCharacters((prev) => { - // 避免重复添加 - if (prev.find((c) => c.id === character.id)) return prev; - return [...prev, character]; - }); - }} onNavigateToSettings={handleNavigateToSkillSettings} /> - - )} - - + )} + + {showChatLayout && ( + <> + {(workspacePathMissing || workspaceHealthError) && ( +
+ + 工作区目录不存在,请重新选择一个本地目录后继续 + + + +
+ )} + {!contextWorkspace.enabled ? inputbarNode : null} + + )} +
+
+ ), + [ + activeTheme, + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + contentId, + contextWorkspace.enabled, + creationMode, + currentStepIndex, + deleteMessage, + dismissWorkspacePathError, + editMessage, + executionStrategy, + generalCanvasState.content, + goToStep, + handleA2UISubmit, + handleCodeBlockClick, + handleFileClick, + handleManageProviders, + handleNavigateToSkillSettings, + handlePermissionResponse, + handleSelectWorkspaceDirectory, + handleSend, + handleWriteFile, + hasMessages, + hideInlineStepProgress, + input, + inputbarNode, + isContentCreationMode, + isThemeWorkbench, + lockTheme, + messages, + model, + projectMemory?.characters, + providerType, + setCreationMode, + setExecutionStrategy, + setInput, + setModel, + setProviderType, + setWorkspaceHealthError, + shouldCollapseCodeBlocks, + selectedText, + showChatLayout, + skills, + steps, + workspaceHealthError, + workspacePathMissing, + resolvedCanvasState, + ], ); // 画布区域内容 const canvasContent = useMemo(() => { + const renderCanvasTheme = ( + shouldBootstrapCanvasOnEntry ? normalizedEntryTheme : mappedTheme + ) as ThemeType; + // 如果有 artifact,优先使用 ArtifactRenderer 渲染 const currentArtifact = selectedArtifact || (artifacts.length > 0 ? artifacts[artifacts.length - 1] : null); - if (activeTheme === "general" && currentArtifact) { + if (renderCanvasTheme === "general" && currentArtifact) { return (
{/* 使用 ArtifactToolbar 组件 */} @@ -2231,7 +5251,7 @@ export function AgentChatPage({ } // General 主题使用专门的预览画布(无 artifact 时) - if (activeTheme === "general") { + if (renderCanvasTheme === "general") { if (generalCanvasState.isOpen) { return ( + {isInitialContentLoading + ? "正在加载文稿内容..." + : initialContentLoadError || "正在准备文稿画布..."} +
+ ); + } + // 其他主题使用 CanvasFactory - if (canvasState) { + if (resolvedCanvasState) { return ( - {showChatPanel && showSidebar && ( - - )} - + const mainAreaNode = useMemo( + () => ( {!hideTopBar && ( <> @@ -2318,17 +5378,11 @@ export function AgentChatPage({ showHistoryToggle={!hideHistoryToggle && showChatPanel} onToggleFullscreen={() => {}} onBackToProjectManagement={onBackToProjectManagement} - onBackToResources={fromResources ? handleBackToResources : undefined} + onBackToResources={ + fromResources ? handleBackToResources : undefined + } projectId={projectId ?? null} - onProjectChange={(newProjectId) => { - if (externalProjectId) { - return; - } - pendingTopicSwitchRef.current = null; - isResolvingTopicProjectRef.current = false; - savePersistedProjectId(LAST_PROJECT_ID_KEY, newProjectId); - setInternalProjectId(newProjectId); - }} + onProjectChange={handleProjectChange} workspaceType={activeTheme} onBackHome={handleBackHome} onToggleSettings={() => { @@ -2348,14 +5402,15 @@ export function AgentChatPage({ } /> - + {!isThemeWorkbench ? ( + + ) : null} - {/* 同步状态指示器 */} - {contentId && syncStatus !== "idle" && ( + {!isThemeWorkbench && contentId && syncStatus !== "idle" && (
)} - {/* 使用布局过渡组件 */} - + + + + {isThemeWorkbench && showChatLayout ? ( + + {inputbarNode} + + ) : null} + ), + [ + _onNavigate, + activeTheme, + canvasContent, + chatContent, + contentId, + currentGate.status, + fromResources, + handleAddNovelChapter, + handleBackHome, + handleBackToResources, + handleCloseCanvas, + handleProjectChange, + handleToggleNovelChapterList, + handleToggleSidebar, + hideHistoryToggle, + hideTopBar, + inputbarNode, + isSending, + isThemeWorkbench, + layoutMode, + novelChapterListCollapsed, + onBackToProjectManagement, + projectId, + showChatLayout, + showChatPanel, + showNovelNavbarControls, + syncStatus, + themeWorkbenchRunState, + ], + ); + + // ========== 渲染逻辑 ========== + + // 所有主题统一使用 useAgentChat 的状态和渲染逻辑 + // General 主题与其他主题的区别仅在于不显示步骤进度条 + return ( + + {isThemeWorkbench ? ( + themeWorkbenchSidebarNode + ) : showChatPanel && showSidebar ? ( + + ) : null} + {showThemeWorkbenchLeftExpandButton ? ( + setThemeWorkbenchSidebarCollapsed(false)} + title="展开上下文侧栏" + > + + + ) : null} + + {mainAreaNode} ); } diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 53b4ecd80..56f513f76 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -102,6 +102,8 @@ export interface Message { contentParts?: ContentPart[]; /** 上下文准备轨迹(可选) */ contextTrace?: ContextTraceStep[]; + /** 消息用途(用于跳过特定副作用) */ + purpose?: "content_review"; } export interface ChatSession { diff --git a/src/components/agent/chat/utils/contextSearch.test.ts b/src/components/agent/chat/utils/contextSearch.test.ts new file mode 100644 index 000000000..9e2b31056 --- /dev/null +++ b/src/components/agent/chat/utils/contextSearch.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSearchContextResult } from "./contextSearch"; + +describe("normalizeSearchContextResult", () => { + it("应优先解析 JSON 结果", () => { + const result = normalizeSearchContextResult( + JSON.stringify({ + title: "智能体市场观察", + summary: "市场讨论聚焦推理成本、工作流平台和企业落地节奏。", + citations: [ + { title: "官方博客", url: "https://example.com/blog" }, + ], + }), + "智能体市场 2026", + "web", + ); + + expect(result.title).toBe("智能体市场观察"); + expect(result.summary).toContain("推理成本"); + expect(result.citations).toEqual([ + { title: "官方博客", url: "https://example.com/blog" }, + ]); + }); + + it("JSON 不可解析时应回退到文本与链接提取", () => { + const result = normalizeSearchContextResult( + [ + "2026 年社交媒体讨论聚焦 Agent 产品的真实 ROI。", + "参考链接:", + "[小红书热议](https://example.com/xhs)", + "https://example.com/weibo", + ].join("\n"), + "Agent 社媒讨论", + "social", + ); + + expect(result.title).toContain("Agent 社媒讨论"); + expect(result.summary).toContain("真实 ROI"); + expect(result.citations).toEqual([ + { title: "小红书热议", url: "https://example.com/xhs" }, + { title: "example.com", url: "https://example.com/weibo" }, + ]); + }); +}); diff --git a/src/components/agent/chat/utils/contextSearch.ts b/src/components/agent/chat/utils/contextSearch.ts new file mode 100644 index 000000000..164001a8a --- /dev/null +++ b/src/components/agent/chat/utils/contextSearch.ts @@ -0,0 +1,283 @@ +import { safeInvoke } from "@/lib/dev-bridge"; + +const FALLBACK_SUMMARY_LENGTH = 420; + +export type ThemeContextSearchMode = "web" | "social"; + +export interface SearchCitation { + title: string; + url: string; +} + +export interface ThemeContextSearchResult { + title: string; + summary: string; + citations: SearchCitation[]; + rawResponse: string; + attemptsSummary?: string; +} + +interface SearchThemeContextOptions { + workspaceId: string; + projectId?: string; + providerType: string; + model: string; + query: string; + mode: ThemeContextSearchMode; +} + +interface ParsedSearchResultPayload { + title?: string; + summary?: string; + citations?: SearchCitation[]; +} + +interface ThemeContextSearchCommandResponse { + title?: string; + summary?: string; + citations?: SearchCitation[]; + rawResponse?: string; + attemptsSummary?: string; +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function stripCodeFence(value: string): string { + return value + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); +} + +function parseJsonObject(rawResponse: string): ParsedSearchResultPayload | null { + const trimmed = rawResponse.trim(); + if (!trimmed) { + return null; + } + + const candidates = [trimmed]; + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fencedMatch?.[1]) { + candidates.unshift(fencedMatch[1]); + } + + const jsonBlockMatch = trimmed.match(/\{[\s\S]*\}/); + if (jsonBlockMatch?.[0]) { + candidates.unshift(jsonBlockMatch[0]); + } + + for (const candidate of candidates) { + const normalized = stripCodeFence(candidate); + try { + const parsed = JSON.parse(normalized) as Record; + const citationsRaw = Array.isArray(parsed.citations) + ? parsed.citations + : Array.isArray(parsed.sources) + ? parsed.sources + : []; + const citations = citationsRaw + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Record; + const url = typeof record.url === "string" ? record.url.trim() : ""; + const title = + typeof record.title === "string" + ? normalizeWhitespace(record.title) + : typeof record.name === "string" + ? normalizeWhitespace(record.name) + : ""; + if (!url) { + return null; + } + return { + title: title || buildCitationTitleFromUrl(url), + url, + } satisfies SearchCitation; + }) + .filter((item): item is SearchCitation => Boolean(item)); + + return { + title: + typeof parsed.title === "string" + ? normalizeWhitespace(parsed.title) + : undefined, + summary: + typeof parsed.summary === "string" + ? normalizeWhitespace(parsed.summary) + : typeof parsed.content === "string" + ? normalizeWhitespace(parsed.content) + : undefined, + citations, + }; + } catch { + continue; + } + } + + return null; +} + +function sanitizeUrl(url: string): string { + return url.replace(/[),.;!?]+$/g, "").trim(); +} + +function buildCitationTitleFromUrl(url: string): string { + try { + const parsed = new URL(url); + return parsed.hostname.replace(/^www\./, ""); + } catch { + return "来源链接"; + } +} + +function extractCitationsFromText(rawResponse: string): SearchCitation[] { + const citations: SearchCitation[] = []; + const seenUrls = new Set(); + + const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g; + for (const match of rawResponse.matchAll(markdownLinkRegex)) { + const url = sanitizeUrl(match[2] || ""); + const title = normalizeWhitespace(match[1] || ""); + if (!url || seenUrls.has(url)) { + continue; + } + seenUrls.add(url); + citations.push({ + title: title || buildCitationTitleFromUrl(url), + url, + }); + } + + const plainUrlRegex = /https?:\/\/[^\s)\]]+/g; + for (const match of rawResponse.matchAll(plainUrlRegex)) { + const url = sanitizeUrl(match[0] || ""); + if (!url || seenUrls.has(url)) { + continue; + } + seenUrls.add(url); + citations.push({ + title: buildCitationTitleFromUrl(url), + url, + }); + } + + return citations.slice(0, 5); +} + +function buildFallbackSummary(rawResponse: string): string { + const normalized = normalizeWhitespace( + stripCodeFence(rawResponse) + .replace(/"citations"\s*:\s*\[[\s\S]*?\]/g, "") + .replace(/[{}[\]"]+/g, " "), + ); + + if (!normalized) { + return "暂无可用摘要,请重新尝试检索。"; + } + + if (normalized.length <= FALLBACK_SUMMARY_LENGTH) { + return normalized; + } + + return `${normalized.slice(0, FALLBACK_SUMMARY_LENGTH)}...`; +} + +function buildFallbackTitle( + query: string, + mode: ThemeContextSearchMode, +): string { + const suffix = mode === "social" ? "社媒搜索上下文" : "网络搜索上下文"; + return `${query.trim()} · ${suffix}`; +} + +export function normalizeSearchContextResult( + rawResponse: string, + query: string, + mode: ThemeContextSearchMode, +): ThemeContextSearchResult { + const parsed = parseJsonObject(rawResponse); + const citations = + parsed?.citations && parsed.citations.length > 0 + ? parsed.citations.slice(0, 5) + : extractCitationsFromText(rawResponse); + + return { + title: parsed?.title || buildFallbackTitle(query, mode), + summary: parsed?.summary || buildFallbackSummary(rawResponse), + citations, + rawResponse, + }; +} + +function normalizeCommandResult( + payload: ThemeContextSearchCommandResponse, + query: string, + mode: ThemeContextSearchMode, +): ThemeContextSearchResult { + const normalizedRawResponse = payload.rawResponse?.trim() || ""; + const fallback = normalizedRawResponse + ? normalizeSearchContextResult(normalizedRawResponse, query, mode) + : null; + + return { + title: normalizeWhitespace(payload.title || "") || fallback?.title || buildFallbackTitle(query, mode), + summary: + normalizeWhitespace(payload.summary || "") || + fallback?.summary || + buildFallbackSummary(normalizedRawResponse || query), + citations: + Array.isArray(payload.citations) && payload.citations.length > 0 + ? payload.citations.slice(0, 5) + : fallback?.citations || [], + rawResponse: normalizedRawResponse || fallback?.rawResponse || "", + attemptsSummary: payload.attemptsSummary, + }; +} + +export async function searchThemeContextWithWebSearch({ + workspaceId, + projectId, + providerType, + model, + query, + mode, +}: SearchThemeContextOptions): Promise { + const trimmedWorkspaceId = workspaceId.trim(); + const trimmedProviderType = providerType.trim(); + const trimmedModel = model.trim(); + const trimmedQuery = query.trim(); + + if (!trimmedWorkspaceId) { + throw new Error("缺少 workspaceId,无法执行上下文搜索"); + } + if (!trimmedProviderType || !trimmedModel) { + throw new Error("当前未选择可用模型,无法执行上下文搜索"); + } + if (!trimmedQuery) { + throw new Error("搜索词不能为空"); + } + + const payload = await safeInvoke( + "aster_agent_theme_context_search", + { + request: { + workspaceId: trimmedWorkspaceId, + projectId: projectId?.trim() || undefined, + providerType: trimmedProviderType, + model: trimmedModel, + query: trimmedQuery, + mode, + }, + }, + ); + + const result = normalizeCommandResult(payload || {}, trimmedQuery, mode); + if (!result.summary.trim()) { + throw new Error("上下文搜索未返回可用内容,请重试"); + } + return result; +} diff --git a/src/components/agent/chat/utils/extractDocumentContent.test.ts b/src/components/agent/chat/utils/extractDocumentContent.test.ts new file mode 100644 index 000000000..eeb393e00 --- /dev/null +++ b/src/components/agent/chat/utils/extractDocumentContent.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +/** + * 测试 extractDocumentContent 函数的逻辑 + * 注意:这是一个独立的测试文件,用于验证内容提取逻辑 + */ + +// 模拟 extractDocumentContent 函数的逻辑 +function extractDocumentContent( + content: string, + isThemeWorkbench: boolean, +): string | null { + // 1. 检查 标签 + const documentMatch = content.match(/([\s\S]*?)<\/document>/); + if (documentMatch) { + return documentMatch[1].trim(); + } + + // 2. 检查 markdown 代码块 + const markdownMatch = content.match(/```(?:markdown|md)\n([\s\S]*?)```/); + if (markdownMatch) { + return markdownMatch[1].trim(); + } + + // 3. 主题工作台:不使用启发式规则,避免误判普通回复 + if (isThemeWorkbench) { + return null; + } + + // 4. 非主题工作台:如果整个内容以 # 开头且长度超过 200 字符,认为是文档 + if (content.trim().startsWith("#") && content.length > 200) { + return content.trim(); + } + + return null; +} + +describe("extractDocumentContent", () => { + describe("明确标记的内容", () => { + it("应提取 标签内的内容", () => { + const content = ` +这是一些说明文字 + +# 文档标题 +这是文档内容 + +更多说明 + `; + expect(extractDocumentContent(content, true)).toBe( + "# 文档标题\n这是文档内容", + ); + expect(extractDocumentContent(content, false)).toBe( + "# 文档标题\n这是文档内容", + ); + }); + + it("应提取 markdown 代码块内的内容", () => { + const content = ` +这是一些说明文字 +\`\`\`markdown +# 文档标题 +这是文档内容 +\`\`\` +更多说明 + `; + expect(extractDocumentContent(content, true)).toBe( + "# 文档标题\n这是文档内容", + ); + expect(extractDocumentContent(content, false)).toBe( + "# 文档标题\n这是文档内容", + ); + }); + }); + + describe("主题工作台模式", () => { + it("不应提取普通对话(即使以 # 开头且较长)", () => { + const longContent = `# 你说得对,这次是我搞错了 +我理解你的意思了。让我重新分析一下这个问题。 +${"这是一段很长的文本。".repeat(20)}`; + + expect(extractDocumentContent(longContent, true)).toBeNull(); + }); + + it("不应提取没有明确标记的内容", () => { + const content = "这是一段普通的对话回复,没有任何标记。"; + expect(extractDocumentContent(content, true)).toBeNull(); + }); + }); + + describe("非主题工作台模式", () => { + it("应提取以 # 开头且长度超过 200 字符的内容", () => { + const longContent = `# 文档标题 +${"这是一段很长的文本。".repeat(20)}`; + + expect(extractDocumentContent(longContent, false)).toBe( + longContent.trim(), + ); + }); + + it("不应提取以 # 开头但长度不足 200 字符的内容", () => { + const shortContent = "# 短标题\n这是一段短文本。"; + expect(extractDocumentContent(shortContent, false)).toBeNull(); + }); + + it("不应提取不以 # 开头的内容", () => { + const content = `${"这是一段很长的文本。".repeat(30)}`; + expect(extractDocumentContent(content, false)).toBeNull(); + }); + }); + + describe("边界情况", () => { + it("应处理空字符串", () => { + expect(extractDocumentContent("", true)).toBeNull(); + expect(extractDocumentContent("", false)).toBeNull(); + }); + + it("应处理只有空白字符的字符串", () => { + expect(extractDocumentContent(" \n\n ", true)).toBeNull(); + expect(extractDocumentContent(" \n\n ", false)).toBeNull(); + }); + + it("应优先提取 标签而不是启发式规则", () => { + const content = ` +# 这是一段很长的文本 +${"内容".repeat(100)} + +# 真正的文档 +这才是要提取的内容 + + `; + expect(extractDocumentContent(content, false)).toBe( + "# 真正的文档\n这才是要提取的内容", + ); + }); + }); +}); diff --git a/src/components/agent/chat/utils/taskFileCanvasSync.test.ts b/src/components/agent/chat/utils/taskFileCanvasSync.test.ts new file mode 100644 index 000000000..6acfb7ce8 --- /dev/null +++ b/src/components/agent/chat/utils/taskFileCanvasSync.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + resolveCanvasTaskFileTarget, + shouldDeferCanvasSyncWhileEditing, +} from "./taskFileCanvasSync"; + +describe("taskFileCanvasSync", () => { + it("优先保留当前选中的可渲染文件", () => { + const files = [ + { + id: "older", + content: "old", + createdAt: 10, + updatedAt: 10, + }, + { + id: "latest", + content: "new", + createdAt: 20, + updatedAt: 20, + }, + ]; + + expect(resolveCanvasTaskFileTarget(files, "older")).toEqual({ + targetFile: files[0], + nextSelectedFileId: null, + }); + }); + + it("未选中文件时回退到最新文件", () => { + const files = [ + { + id: "first", + content: "first", + createdAt: 10, + updatedAt: 10, + }, + { + id: "second", + content: "second", + createdAt: 15, + updatedAt: 30, + }, + ]; + + expect(resolveCanvasTaskFileTarget(files)).toEqual({ + targetFile: files[1], + nextSelectedFileId: "second", + }); + }); + + it("编辑器聚焦时延后文档画布同步", () => { + expect( + shouldDeferCanvasSyncWhileEditing({ + canvasType: "document", + editorFocused: true, + }), + ).toBe(true); + + expect( + shouldDeferCanvasSyncWhileEditing({ + canvasType: "music", + editorFocused: true, + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/utils/taskFileCanvasSync.ts b/src/components/agent/chat/utils/taskFileCanvasSync.ts new file mode 100644 index 000000000..9efe1b7ec --- /dev/null +++ b/src/components/agent/chat/utils/taskFileCanvasSync.ts @@ -0,0 +1,70 @@ +export interface RenderableTaskFileCandidate { + id: string; + content?: string | null; + createdAt: number; + updatedAt: number; +} + +export interface ResolveCanvasTaskFileTargetResult< + T extends RenderableTaskFileCandidate, +> { + targetFile: T | null; + nextSelectedFileId: string | null; +} + +export function resolveCanvasTaskFileTarget< + T extends RenderableTaskFileCandidate, +>( + files: T[], + selectedFileId?: string, +): ResolveCanvasTaskFileTargetResult { + if (files.length === 0) { + return { + targetFile: null, + nextSelectedFileId: null, + }; + } + + const selectedFile = selectedFileId + ? files.find((file) => file.id === selectedFileId) || null + : null; + if (selectedFile?.content) { + return { + targetFile: selectedFile, + nextSelectedFileId: null, + }; + } + + const latestFile = files.reduce((candidate, file) => { + if (!candidate) { + return file; + } + + const candidateTimestamp = Math.max( + candidate.updatedAt, + candidate.createdAt, + ); + const fileTimestamp = Math.max(file.updatedAt, file.createdAt); + return fileTimestamp >= candidateTimestamp ? file : candidate; + }, null); + + if (!latestFile?.content) { + return { + targetFile: null, + nextSelectedFileId: null, + }; + } + + return { + targetFile: latestFile, + nextSelectedFileId: + selectedFileId === latestFile.id ? null : latestFile.id, + }; +} + +export function shouldDeferCanvasSyncWhileEditing(options: { + canvasType: string | null; + editorFocused: boolean; +}): boolean { + return options.editorFocused && options.canvasType === "document"; +} diff --git a/src/components/artifact/canvasAdapterUtils.ts b/src/components/artifact/canvasAdapterUtils.ts index ce7b90f0a..0b914442c 100644 --- a/src/components/artifact/canvasAdapterUtils.ts +++ b/src/components/artifact/canvasAdapterUtils.ts @@ -14,7 +14,7 @@ import type { } from "@/components/content-creator/canvas/canvasUtils"; import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; import { createInitialPosterState } from "@/components/content-creator/canvas/poster"; -import { createInitialMusicState } from "@/components/content-creator/canvas/music"; +import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; import { createInitialScriptState } from "@/components/content-creator/canvas/script"; import { createInitialNovelState } from "@/components/content-creator/canvas/novel"; import { createInitialVideoState } from "@/components/content-creator/canvas/video"; diff --git a/src/components/content-creator/a2ui/components/form/TextField.tsx b/src/components/content-creator/a2ui/components/form/TextField.tsx index 6d6a722d3..7880be500 100644 --- a/src/components/content-creator/a2ui/components/form/TextField.tsx +++ b/src/components/content-creator/a2ui/components/form/TextField.tsx @@ -3,6 +3,7 @@ * @description 文本输入框 */ +import { useCallback, useEffect, useRef, useState } from "react"; import type { TextFieldComponent, A2UIFormData } from "../../types"; import { resolveDynamicValue } from "../../parser"; @@ -24,14 +25,68 @@ export function TextFieldRenderer({ (formData[component.id] as string) ?? String(resolveDynamicValue(component.value, data, "")); const isLongText = component.variant === "longText"; + const commitFrameRef = useRef(null); + const latestLocalValueRef = useRef(value); + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + latestLocalValueRef.current = value; + setLocalValue(value); + }, [value]); + + const commitValue = useCallback( + (nextValue: string) => { + onFormChange(component.id, nextValue); + }, + [component.id, onFormChange], + ); + + const scheduleCommit = useCallback( + (nextValue: string) => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + } + commitFrameRef.current = requestAnimationFrame(() => { + commitValue(nextValue); + commitFrameRef.current = null; + }); + }, + [commitValue], + ); + + const handleInputChange = useCallback( + (nextValue: string) => { + latestLocalValueRef.current = nextValue; + setLocalValue(nextValue); + scheduleCommit(nextValue); + }, + [scheduleCommit], + ); + + const handleBlur = useCallback(() => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + commitFrameRef.current = null; + } + commitValue(latestLocalValueRef.current); + }, [commitValue]); + + useEffect(() => { + return () => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + } + }; + }, []); return (
{label && } {isLongText ? (