diff --git a/AGENTS.md b/AGENTS.md index 9579fc45c..405d3a4db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,13 @@ 2. **文件超过 20 行,分批输出** - 避免一次性输出过长内容 3. **先读后写** - 修改文件前必须先读取现有内容 +## AGENTS.md 维护原则 + +1. **根 AGENTS.md 只放仓库级规则** - 保留全局约束、高频命令、文档索引,避免塞入过长操作手册 +2. **长流程拆到独立文档** - 像 Playwright E2E、内容创作工作流这类步骤型说明,放到 `docs/aiprompts/`,根 AGENTS 只保留入口 +3. **谨慎新增子目录 AGENTS.md** - 仅当某个目录树存在长期稳定、只对该子树生效的规则时才新增;临时排障说明不要新增 AGENTS +4. **优先索引化而不是堆叠说明** - 根 AGENTS 更适合作为目录与约定入口,详细上下文交给专门文档 + ## 详细文档 模块级详细文档位于 `docs/aiprompts/`: @@ -19,7 +26,6 @@ | [credential-pool.md](docs/aiprompts/credential-pool.md) | 凭证池管理 | | [converter.md](docs/aiprompts/converter.md) | 协议转换 | | [server.md](docs/aiprompts/server.md) | HTTP 服务器 | -| [flow-monitor.md](docs/aiprompts/flow-monitor.md) | 流量监控 | | [components.md](docs/aiprompts/components.md) | 组件系统 | | [hooks.md](docs/aiprompts/hooks.md) | React Hooks | | [services.md](docs/aiprompts/services.md) | 业务服务 | @@ -29,6 +35,10 @@ | [terminal.md](docs/aiprompts/terminal.md) | 内置终端 | | [plugins.md](docs/aiprompts/plugins.md) | 插件系统 | | [lib.md](docs/aiprompts/lib.md) | 工具库 | +| [workspace.md](docs/aiprompts/workspace.md) | Workspace 设计文档 | +| [content-creator.md](docs/aiprompts/content-creator.md) | 内容创作系统 | +| [aster-integration.md](docs/aiprompts/aster-integration.md) | Aster 集成方案 | +| [playwright-e2e.md](docs/aiprompts/playwright-e2e.md) | Playwright MCP 续测与 E2E 指南 | ## 构建命令 @@ -40,7 +50,10 @@ cd src-tauri && cargo build npm run build # 开发模式 -npm run tauri dev +npm run tauri:dev + +# 浏览器 + DevBridge + Playwright MCP 调试模式 +npm run tauri:dev:headless ``` ## 测试命令 @@ -51,6 +64,9 @@ cd src-tauri && cargo test # 运行前端测试 npm test + +# 运行针对浏览器桥接的前端测试 +npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts ``` ## 代码检查 @@ -63,6 +79,11 @@ cd src-tauri && cargo clippy npm run lint ``` +## Playwright E2E 入口 + +- 需要继续浏览器 E2E、复用现有 Playwright MCP 会话、排查 DevBridge/console 错误时,先读 `docs/aiprompts/playwright-e2e.md` +- 如果只是仓库级规则,不要继续往本文件堆叠步骤说明 + ## 项目架构 ### 技术栈 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index c82d2f638..ec2b9584a 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -27,10 +27,10 @@ AI Agent 专用文档目录,提供模块级别的详细说明。 - `database.md` - 数据库层(SQLite) ### 功能模块 -- `flow-monitor.md` - LLM 流量监控 - `terminal.md` - 内置终端 - `mcp.md` - MCP 服务器管理 - `plugins.md` - 插件系统 +- `playwright-e2e.md` - Playwright MCP 续测与 E2E 指南 ### Aster 集成 - `aster-integration.md` - **Aster 框架集成方案** @@ -58,6 +58,9 @@ AI Agent 在处理特定模块时,应先阅读对应的 aiprompts 文档: # 处理内容创作、画布联动 → 先读 docs/aiprompts/content-creator.md + +# 处理浏览器 E2E、Playwright MCP 续测 +→ 先读 docs/aiprompts/playwright-e2e.md ``` ## 更新提醒 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md new file mode 100644 index 000000000..a1d1a6714 --- /dev/null +++ b/docs/aiprompts/playwright-e2e.md @@ -0,0 +1,219 @@ +# Playwright MCP 续测与 E2E 指南 + +## 目的 + +本文件用于指导 AI Agent 在 ProxyCast 中继续进行浏览器端 E2E 测试,特别适用于以下场景: + +- 用户说“继续测试”“继续复现”“继续用 Playwright MCP 验证” +- 需要复用当前浏览器标签页和已有页面状态 +- 需要排查浏览器模式下的 DevBridge、mock fallback、控制台报错 + +## 适用边界 + +- **优先使用 Playwright MCP** 做交互验证,不优先编写新的本地 Playwright 测试文件 +- **浏览器模式默认首页不变**,从 `http://127.0.0.1:1420/` 进入 +- **能走真实后端就走真实后端**;浏览器模式暂不支持或尚未桥接的能力,允许继续走 mock +- 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测,不要强行启动整条 E2E + +## AGENTS.md 最佳实践(本仓库落地版) + +为避免 `AGENTS.md` 膨胀,遵循以下组织方式: + +1. **根 `AGENTS.md` 只保留仓库级规则和入口索引** +2. **长流程文档放 `docs/aiprompts/`**,例如本文件 +3. **只有某个子目录存在长期稳定、强作用域规则时,才新增子目录 `AGENTS.md`** +4. **临时排障记录不要写进 `AGENTS.md`**,应写入普通文档或直接在任务对话里说明 + +## 启动方式 + +### 推荐启动命令 + +```bash +npm run tauri:dev:headless +``` + +用途: +- 启动前端 dev server +- 启动 Tauri headless 调试环境 +- 启动浏览器模式所需的 DevBridge +- 便于 Playwright MCP 访问 `http://127.0.0.1:1420/` + +### 针对性前端校验 + +```bash +npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts +``` + +适用时机: +- 修改了 `safeInvoke` +- 修改了 `src/lib/tauri-mock/` +- 修改了浏览器模式 bridge/mock 优先级 + +## 继续测试的标准流程 + +### 1. 先确认当前 Playwright 会话是否可复用 + +优先顺序: + +1. 调用 `browser_tabs` 查看当前标签页 +2. 如果已有 `ProxyCast` 标签页,先查看当前 URL、标题、页面状态 +3. 如果页面已漂移到旧状态,直接重新导航到 `http://127.0.0.1:1420/` + +建议: +- **继续测试优先复用当前标签页**,避免无意义重复建页 +- **如果控制台历史噪音太多,刷新页面重新计数** + +### 2. 进入页面前先检查加载状态 + +推荐动作: + +1. 打开页面后等待“正在加载...”消失 +2. 使用 `browser_snapshot` 确认首页核心元素已出现 +3. 立刻检查一次 `browser_console_messages(level=error)` + +通过标准: +- 首页成功加载 +- 默认首页可交互 +- 初始控制台 error 为 0;若非 0,需要先定位是否为 bridge 缺口 + +### 3. 交互时优先使用稳定定位方式 + +遵循 Playwright 官方最佳实践: + +- 优先使用 **角色、名称、可见文本** 定位 +- 优先使用 Playwright 自带等待与 web-first 断言 +- **不要依赖固定 sleep** 代替状态判断 +- 点击前先确认元素可见、可交互 + +本仓库中建议优先使用: +- `button` + 中文名称 +- 页面中明确可见的标题文本 +- `browser_snapshot` 返回的 ref 作为精确交互目标 + +## ProxyCast 推荐 E2E 主路径 + +### 首页基础验证 + +1. 打开 `http://127.0.0.1:1420/` +2. 等待默认首页加载完成 +3. 验证首页主导航可见:如“首页”“社媒内容”“设置” +4. 检查 `browser_console_messages(level=error)` 为 0 + +### 社媒内容工作流验证 + +1. 点击 `社媒内容` +2. 如果没有项目:点击 `新建项目` +3. 如果已有项目:直接选择目标项目 +4. 点击 `新建文稿` +5. 选择 `新开帖子(创建新文稿)` +6. 点击 `确认生成` +7. 验证页面出现 `Theme Workbench` 或相关工作台内容 +8. 再次检查 `browser_console_messages(level=error)` + +### 素材页验证 + +1. 从社媒内容项目进入 `素材` +2. 验证素材列表可加载 +3. 验证素材计数、列表项或空状态正常显示 +4. 检查控制台无新增 error + +## 每一步都要记录什么 + +执行 Playwright MCP 续测时,至少记录以下事实: + +- 当前页面 URL +- 当前关键可见文本 +- 是否走到了真实 bridge +- 是否触发了 mock fallback +- 控制台 error 数量 +- 如失败,明确失败命令名(例如某个 invoke command) + +推荐结论格式: + +- 页面是否可打开 +- 业务流是否走通 +- 控制台是否归零 +- 新暴露的命令缺口是什么 +- 该缺口适合补真实 bridge 还是补 mock + +## 浏览器模式常见故障与处理 + +### 1. `Cannot read properties of undefined (reading 'invoke')` + +通常表示: +- 浏览器里加载了真实 Tauri API 包 +- 没有走 web mock / HTTP bridge 链路 + +优先排查: +- 是否使用了浏览器模式专用启动方式 +- Vite 是否正确走了 web alias +- 当前页面是否需要强制刷新以拿到最新前端代码 + +### 2. `[DevBridge] 未知命令` + +说明: +- 前端已调用某命令 +- 浏览器 bridge 分发器没有实现 + +处理顺序: +1. 先判断该命令是否应走真实后端 +2. 如果该能力在浏览器模式下不是关键阻塞项,可加入 mock 优先集合 +3. 如果该命令属于核心业务路径,优先补 `dispatcher.rs` + +### 3. `Failed to fetch` + +常见原因: +- DevBridge 没启动 +- 3030 端口不可用 +- 前端先于 bridge 就绪开始调用 + +处理建议: +- 确认 `tauri:dev:headless` 已启动 +- 检查 bridge 健康接口 +- 刷新页面后复测,排除启动时序问题 + +### 4. UI 已可用但控制台仍报错 + +说明: +- 页面可能依赖 fallback mock 继续运行 +- 但仍有命令先打到了 bridge 并报 unknown command + +处理建议: +- 若该命令属于浏览器模式可接受的降级能力,加入 mock 优先列表 +- 若该命令属于当前主路径必须能力,补真实 bridge + +## 何时补 mock,何时补真实 bridge + +### 优先补真实 bridge + +适用于: +- 当前主路径必须命令 +- 明确已有后端实现 +- 返回结构简单稳定 +- 不涉及复杂流式事件或强原生依赖 + +### 优先补 mock + +适用于: +- 浏览器模式不支持的原生能力 +- 非主路径功能 +- 高频噪音命令,但不影响主流程完成 +- 流式/系统级能力,短期内 bridge 成本高于收益 + +## 结果判定标准 + +一次“继续测试”完成后,至少满足以下之一: + +1. **主路径走通且控制台 error 归零** +2. **主路径走通,且剩余错误已被明确归类为非阻塞项** +3. **已定位新的 bridge 缺口,并给出下一步最小修复点** + +## 给后续 Agent 的交接要求 + +如果本轮没有完全收口,请在结论中明确留下: + +- 当前停留页面 +- 已完成的业务步骤 +- 最新暴露的命令缺口 +- 推荐下一步先补 mock 还是先补 bridge +- 下一轮建议的 Playwright 复测路径 diff --git a/package.json b/package.json index 986629aa6..4060abf03 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "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", - "smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs" + "smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs", + "dev:web-bridge": "node scripts/start-web-bridge-dev.mjs" }, "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/scripts/start-web-bridge-dev.mjs b/scripts/start-web-bridge-dev.mjs new file mode 100755 index 000000000..f9292b67b --- /dev/null +++ b/scripts/start-web-bridge-dev.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; + +const env = { ...process.env }; +delete env.TAURI_ENV_PLATFORM; + +env.PROXYCAST_BROWSER_BRIDGE = '1'; + +const child = spawn('npx', ['vite'], { + stdio: 'inherit', + shell: true, + env, +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/src-tauri/crates/core/src/agent/types.rs b/src-tauri/crates/core/src/agent/types.rs index b37ec5d25..47163f992 100644 --- a/src-tauri/crates/core/src/agent/types.rs +++ b/src-tauri/crates/core/src/agent/types.rs @@ -37,7 +37,7 @@ pub enum ProviderType { impl ProviderType { /// 从字符串解析 provider 类型 - pub fn from_str(s: &str) -> Self { + pub fn parse(s: &str) -> Self { match s.to_lowercase().as_str() { "claude" | "anthropic" => Self::Claude, "claude_oauth" => Self::ClaudeOauth, @@ -65,7 +65,7 @@ impl ProviderType { } // 对于其他 Provider,尝试直接解析 - let provider_type = Self::from_str(provider); + let provider_type = Self::parse(provider); // 如果能被识别,直接返回 if !matches!(provider_type, Self::OpenAI) || provider.eq_ignore_ascii_case("openai") { diff --git a/src-tauri/crates/core/src/config/hot_reload.rs b/src-tauri/crates/core/src/config/hot_reload.rs index 28be048ad..7f78fed6b 100644 --- a/src-tauri/crates/core/src/config/hot_reload.rs +++ b/src-tauri/crates/core/src/config/hot_reload.rs @@ -20,6 +20,7 @@ use tokio::sync::mpsc; /// 热重载错误类型 #[derive(Debug, Clone)] #[allow(dead_code)] +#[allow(clippy::enum_variant_names)] pub enum HotReloadError { /// 文件监控错误 WatchError(String), @@ -491,14 +492,12 @@ fn is_valid_bind_host(host: &str) -> bool { } // 允许私有网络地址 - if let Ok(addr) = host.parse::() { - if let std::net::IpAddr::V4(ipv4) = addr { - // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 - let octets = ipv4.octets(); - return octets[0] == 10 - || (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)) - || (octets[0] == 192 && octets[1] == 168); - } + if let Ok(std::net::IpAddr::V4(ipv4)) = host.parse::() { + // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + let octets = ipv4.octets(); + return octets[0] == 10 + || (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)) + || (octets[0] == 192 && octets[1] == 168); } false @@ -511,13 +510,11 @@ fn is_non_local_bind(host: &str) -> bool { } // 私有网络地址也算非本地绑定 - if let Ok(addr) = host.parse::() { - if let std::net::IpAddr::V4(ipv4) = addr { - let octets = ipv4.octets(); - return octets[0] == 10 - || (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)) - || (octets[0] == 192 && octets[1] == 168); - } + if let Ok(std::net::IpAddr::V4(ipv4)) = host.parse::() { + let octets = ipv4.octets(); + return octets[0] == 10 + || (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)) + || (octets[0] == 192 && octets[1] == 168); } false diff --git a/src-tauri/crates/core/src/config/import.rs b/src-tauri/crates/core/src/config/import.rs index 6954417a3..d7805c101 100644 --- a/src-tauri/crates/core/src/config/import.rs +++ b/src-tauri/crates/core/src/config/import.rs @@ -131,6 +131,7 @@ impl ImportResult { /// 导入错误类型 #[derive(Debug, Clone)] +#[allow(clippy::enum_variant_names)] pub enum ImportError { /// 格式错误 FormatError(String), diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 9ed9ed840..4745280d7 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -46,10 +46,11 @@ pub struct CredentialPoolConfig { // ============ ASR 语音服务配置类型 ============ /// ASR Provider 类型 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum AsrProviderType { /// 本地 Whisper(离线) + #[default] WhisperLocal, /// 讯飞语音识别 Xunfei, @@ -59,19 +60,15 @@ pub enum AsrProviderType { OpenAI, } -impl Default for AsrProviderType { - fn default() -> Self { - Self::WhisperLocal - } -} - /// Whisper 模型大小 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default)] #[serde(rename_all = "snake_case")] pub enum WhisperModelSize { /// tiny - 最小,最快(~75MB) Tiny, /// base - 基础(~142MB) + #[default] Base, /// small - 小型(~466MB) Small, @@ -79,12 +76,6 @@ pub enum WhisperModelSize { Medium, } -impl Default for WhisperModelSize { - fn default() -> Self { - Self::Base - } -} - /// ASR 凭证条目 /// /// 用于语音识别服务的凭证管理 @@ -948,10 +939,11 @@ impl Default for VoiceOutputConfig { } /// 语音输出模式 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum VoiceOutputMode { /// 模拟键盘输入 + #[default] Type, /// 复制到剪贴板 Clipboard, @@ -959,12 +951,6 @@ pub enum VoiceOutputMode { Both, } -impl Default for VoiceOutputMode { - fn default() -> Self { - Self::Type - } -} - /// 语音处理指令 /// /// 定义不同的文本处理模式,如默认润色、翻译、邮件格式等 diff --git a/src-tauri/crates/core/src/content/manager.rs b/src-tauri/crates/core/src/content/manager.rs index e0fd62ace..bae2732e8 100644 --- a/src-tauri/crates/core/src/content/manager.rs +++ b/src-tauri/crates/core/src/content/manager.rs @@ -2,6 +2,8 @@ //! //! 提供 Content 的 CRUD 操作。 +use std::str::FromStr; + use super::types::{ Content, ContentCreateRequest, ContentId, ContentListQuery, ContentStatus, ContentType, ContentUpdateRequest, @@ -383,8 +385,8 @@ impl ContentManager { id, project_id, title, - content_type: ContentType::from_str(&content_type_str), - status: ContentStatus::from_str(&status_str), + content_type: ContentType::from_str(&content_type_str).unwrap_or_default(), + status: ContentStatus::from_str(&status_str).unwrap_or_default(), order, body, word_count, diff --git a/src-tauri/crates/core/src/content/types.rs b/src-tauri/crates/core/src/content/types.rs index 7c121c6ab..35f65191f 100644 --- a/src-tauri/crates/core/src/content/types.rs +++ b/src-tauri/crates/core/src/content/types.rs @@ -36,17 +36,6 @@ impl ContentType { } } - pub fn from_str(s: &str) -> Self { - match s { - "episode" => ContentType::Episode, - "chapter" => ContentType::Chapter, - "post" => ContentType::Post, - "document" => ContentType::Document, - "content" => ContentType::Content, - _ => ContentType::Document, - } - } - /// 获取内容类型的显示名称 pub fn display_name(&self) -> &'static str { match self { @@ -59,6 +48,21 @@ impl ContentType { } } +impl std::str::FromStr for ContentType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "episode" => Ok(ContentType::Episode), + "chapter" => Ok(ContentType::Chapter), + "post" => Ok(ContentType::Post), + "document" => Ok(ContentType::Document), + "content" => Ok(ContentType::Content), + _ => Ok(ContentType::Document), + } + } +} + /// 内容状态 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "snake_case")] @@ -72,6 +76,7 @@ pub enum ContentStatus { Published, } +#[allow(dead_code)] impl ContentStatus { pub fn as_str(&self) -> &'static str { match self { @@ -80,13 +85,17 @@ impl ContentStatus { ContentStatus::Published => "published", } } +} - pub fn from_str(s: &str) -> Self { +impl std::str::FromStr for ContentStatus { + type Err = String; + + fn from_str(s: &str) -> Result { match s { - "draft" => ContentStatus::Draft, - "completed" => ContentStatus::Completed, - "published" => ContentStatus::Published, - _ => ContentStatus::Draft, + "draft" => Ok(ContentStatus::Draft), + "completed" => Ok(ContentStatus::Completed), + "published" => Ok(ContentStatus::Published), + _ => Ok(ContentStatus::Draft), } } } 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 0e49691ab..55994c7db 100644 --- a/src-tauri/crates/core/src/database/dao/agent_run.rs +++ b/src-tauri/crates/core/src/database/dao/agent_run.rs @@ -98,6 +98,7 @@ impl AgentRunDao { } /// 仅允许从非终态更新到终态,幂等保护:`finished_at IS NULL` + #[allow(clippy::too_many_arguments)] pub fn finish_run( conn: &Connection, id: &str, diff --git a/src-tauri/crates/core/src/database/dao/chat.rs b/src-tauri/crates/core/src/database/dao/chat.rs index 165264c27..3abcb9802 100644 --- a/src-tauri/crates/core/src/database/dao/chat.rs +++ b/src-tauri/crates/core/src/database/dao/chat.rs @@ -18,23 +18,18 @@ use serde::{Deserialize, Serialize}; // ============================================================================ /// 对话模式 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ChatMode { /// AI Agent 模式,支持工具调用 Agent, /// 通用对话模式,纯文本 + #[default] General, /// 内容创作模式,支持画布输出 Creator, } -impl Default for ChatMode { - fn default() -> Self { - Self::General - } -} - impl std::fmt::Display for ChatMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src-tauri/crates/core/src/database/dao/poster_material_dao.rs b/src-tauri/crates/core/src/database/dao/poster_material_dao.rs index cea6151f3..e5f9d6bcb 100644 --- a/src-tauri/crates/core/src/database/dao/poster_material_dao.rs +++ b/src-tauri/crates/core/src/database/dao/poster_material_dao.rs @@ -450,12 +450,12 @@ impl PosterMaterialDao { // 解析元数据(可能为空) let metadata_material_id: Option = row.get(11)?; - let metadata = if metadata_material_id.is_some() { + let metadata = if let Some(material_id) = metadata_material_id { let colors_json: String = row.get(16)?; let colors: Vec = serde_json::from_str(&colors_json).unwrap_or_default(); Some(PosterMaterialMetadata { - material_id: metadata_material_id.unwrap(), + material_id, image_category: row.get(12)?, width: row.get(13)?, height: row.get(14)?, diff --git a/src-tauri/crates/core/src/database/dao/provider_pool.rs b/src-tauri/crates/core/src/database/dao/provider_pool.rs index 84a892f55..f2a8eb27d 100644 --- a/src-tauri/crates/core/src/database/dao/provider_pool.rs +++ b/src-tauri/crates/core/src/database/dao/provider_pool.rs @@ -214,6 +214,7 @@ impl ProviderPoolDao { } /// 更新健康状态 + #[allow(clippy::too_many_arguments)] pub fn update_health_status( conn: &Connection, uuid: &str, diff --git a/src-tauri/crates/core/src/general_chat/types.rs b/src-tauri/crates/core/src/general_chat/types.rs index 7f937d560..65b49bcac 100644 --- a/src-tauri/crates/core/src/general_chat/types.rs +++ b/src-tauri/crates/core/src/general_chat/types.rs @@ -31,10 +31,11 @@ pub struct ChatSession { /// 消息角色 /// /// 标识消息的发送者类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum MessageRole { /// 用户消息 + #[default] User, /// AI 助手消息 Assistant, @@ -42,19 +43,14 @@ pub enum MessageRole { System, } -impl Default for MessageRole { - fn default() -> Self { - Self::User - } -} - /// 消息状态 /// /// 标识消息的当前处理状态 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum MessageStatus { /// 等待发送 + #[default] Pending, /// 流式生成中 Streaming, @@ -64,12 +60,6 @@ pub enum MessageStatus { Error, } -impl Default for MessageStatus { - fn default() -> Self { - Self::Pending - } -} - /// 内容块 /// /// 表示消息中的一个内容单元,可以是文本、代码、图片或文件 diff --git a/src-tauri/crates/core/src/models/model_registry.rs b/src-tauri/crates/core/src/models/model_registry.rs index 6f28e842f..04ac25b9d 100644 --- a/src-tauri/crates/core/src/models/model_registry.rs +++ b/src-tauri/crates/core/src/models/model_registry.rs @@ -147,6 +147,7 @@ impl std::str::FromStr for ModelTier { /// 模型数据来源 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Default)] #[serde(rename_all = "lowercase")] pub enum ModelSource { /// 从内嵌资源加载(构建时打包) @@ -154,6 +155,7 @@ pub enum ModelSource { /// 从 models.dev API 获取(已弃用) ModelsDev, /// 本地硬编码(国内模型等) + #[default] Local, /// 用户自定义 Custom, @@ -161,12 +163,6 @@ pub enum ModelSource { Api, } -impl Default for ModelSource { - fn default() -> Self { - Self::Local - } -} - impl std::fmt::Display for ModelSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src-tauri/crates/core/src/models/project_model.rs b/src-tauri/crates/core/src/models/project_model.rs index e75d3df7a..4056b5098 100644 --- a/src-tauri/crates/core/src/models/project_model.rs +++ b/src-tauri/crates/core/src/models/project_model.rs @@ -151,10 +151,11 @@ pub struct PersonaTemplate { // ============================================================================ /// 素材类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum MaterialType { /// 文档(PDF、Word 等) + #[default] Document, /// 图片 Image, @@ -176,12 +177,6 @@ pub enum MaterialType { Layout, } -impl Default for MaterialType { - fn default() -> Self { - Self::Document - } -} - #[allow(dead_code)] impl MaterialType { pub fn as_str(&self) -> &'static str { @@ -198,23 +193,30 @@ impl MaterialType { MaterialType::Layout => "layout", } } +} - pub fn from_str(s: &str) -> Self { +impl std::str::FromStr for MaterialType { + type Err = String; + + fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "document" => MaterialType::Document, - "image" => MaterialType::Image, - "audio" => MaterialType::Audio, - "video" => MaterialType::Video, - "text" => MaterialType::Text, - "data" => MaterialType::Data, - "link" => MaterialType::Link, - "icon" => MaterialType::Icon, - "color" => MaterialType::Color, - "layout" => MaterialType::Layout, - _ => MaterialType::Document, + "document" => Ok(MaterialType::Document), + "image" => Ok(MaterialType::Image), + "audio" => Ok(MaterialType::Audio), + "video" => Ok(MaterialType::Video), + "text" => Ok(MaterialType::Text), + "data" => Ok(MaterialType::Data), + "link" => Ok(MaterialType::Link), + "icon" => Ok(MaterialType::Icon), + "color" => Ok(MaterialType::Color), + "layout" => Ok(MaterialType::Layout), + _ => Ok(MaterialType::Document), } } +} +#[allow(dead_code)] +impl MaterialType { /// 判断是否为海报素材类型 pub fn is_poster_material(&self) -> bool { matches!( @@ -225,7 +227,7 @@ impl MaterialType { } /// 图片分类 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum ImageCategory { /// 背景图 @@ -239,15 +241,10 @@ pub enum ImageCategory { /// 纹理图 Texture, /// 其他 + #[default] Other, } -impl Default for ImageCategory { - fn default() -> Self { - Self::Other - } -} - #[allow(dead_code)] impl ImageCategory { pub fn as_str(&self) -> &'static str { @@ -261,17 +258,6 @@ impl ImageCategory { } } - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "background" => ImageCategory::Background, - "product" => ImageCategory::Product, - "person" => ImageCategory::Person, - "decoration" => ImageCategory::Decoration, - "texture" => ImageCategory::Texture, - _ => ImageCategory::Other, - } - } - pub fn display_name(&self) -> &'static str { match self { ImageCategory::Background => "背景", @@ -284,11 +270,27 @@ impl ImageCategory { } } +impl std::str::FromStr for ImageCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "background" => Ok(ImageCategory::Background), + "product" => Ok(ImageCategory::Product), + "person" => Ok(ImageCategory::Person), + "decoration" => Ok(ImageCategory::Decoration), + "texture" => Ok(ImageCategory::Texture), + _ => Ok(ImageCategory::Other), + } + } +} + /// 布局分类 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] pub enum LayoutCategory { /// 大图型 + #[default] HeroImage, /// 文字主导 TextDominant, @@ -302,12 +304,6 @@ pub enum LayoutCategory { Collage, } -impl Default for LayoutCategory { - fn default() -> Self { - Self::HeroImage - } -} - #[allow(dead_code)] impl LayoutCategory { pub fn as_str(&self) -> &'static str { @@ -321,18 +317,6 @@ impl LayoutCategory { } } - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "hero-image" => LayoutCategory::HeroImage, - "text-dominant" => LayoutCategory::TextDominant, - "grid" => LayoutCategory::Grid, - "split" => LayoutCategory::Split, - "minimal" => LayoutCategory::Minimal, - "collage" => LayoutCategory::Collage, - _ => LayoutCategory::HeroImage, - } - } - pub fn display_name(&self) -> &'static str { match self { LayoutCategory::HeroImage => "大图型", @@ -345,6 +329,22 @@ impl LayoutCategory { } } +impl std::str::FromStr for LayoutCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "hero-image" => Ok(LayoutCategory::HeroImage), + "text-dominant" => Ok(LayoutCategory::TextDominant), + "grid" => Ok(LayoutCategory::Grid), + "split" => Ok(LayoutCategory::Split), + "minimal" => Ok(LayoutCategory::Minimal), + "collage" => Ok(LayoutCategory::Collage), + _ => Ok(LayoutCategory::HeroImage), + } + } +} + /// 海报素材元数据 /// /// 存储海报素材的扩展信息,与 materials 表关联。 @@ -555,7 +555,7 @@ pub struct MaterialFilter { // ============================================================================ /// 平台类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum Platform { /// 小红书 @@ -569,15 +569,10 @@ pub enum Platform { /// 抖音 Douyin, /// Markdown 通用格式 + #[default] Markdown, } -impl Default for Platform { - fn default() -> Self { - Self::Markdown - } -} - #[allow(dead_code)] impl Platform { pub fn as_str(&self) -> &'static str { @@ -591,18 +586,6 @@ impl Platform { } } - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "xiaohongshu" => Platform::Xiaohongshu, - "wechat" => Platform::Wechat, - "zhihu" => Platform::Zhihu, - "weibo" => Platform::Weibo, - "douyin" => Platform::Douyin, - "markdown" => Platform::Markdown, - _ => Platform::Markdown, - } - } - /// 获取平台显示名称 pub fn display_name(&self) -> &'static str { match self { @@ -616,24 +599,35 @@ impl Platform { } } +impl std::str::FromStr for Platform { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "xiaohongshu" => Ok(Platform::Xiaohongshu), + "wechat" => Ok(Platform::Wechat), + "zhihu" => Ok(Platform::Zhihu), + "weibo" => Ok(Platform::Weibo), + "douyin" => Ok(Platform::Douyin), + "markdown" => Ok(Platform::Markdown), + _ => Ok(Platform::Markdown), + } + } +} + /// Emoji 使用程度 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum EmojiUsage { /// 大量使用 Heavy, /// 适度使用 + #[default] Moderate, /// 少量使用 Minimal, } -impl Default for EmojiUsage { - fn default() -> Self { - Self::Moderate - } -} - #[allow(dead_code)] impl EmojiUsage { pub fn as_str(&self) -> &'static str { @@ -643,13 +637,17 @@ impl EmojiUsage { EmojiUsage::Minimal => "minimal", } } +} - pub fn from_str(s: &str) -> Self { +impl std::str::FromStr for EmojiUsage { + type Err = String; + + fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "heavy" => EmojiUsage::Heavy, - "moderate" => EmojiUsage::Moderate, - "minimal" => EmojiUsage::Minimal, - _ => EmojiUsage::Moderate, + "heavy" => Ok(EmojiUsage::Heavy), + "moderate" => Ok(EmojiUsage::Moderate), + "minimal" => Ok(EmojiUsage::Minimal), + _ => Ok(EmojiUsage::Moderate), } } } @@ -808,10 +806,11 @@ pub struct ProjectContext { // ============================================================================ /// 品牌个性类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum BrandPersonality { /// 专业严谨 + #[default] Professional, /// 亲切友好 Friendly, @@ -827,12 +826,6 @@ pub enum BrandPersonality { Elegant, } -impl Default for BrandPersonality { - fn default() -> Self { - Self::Professional - } -} - #[allow(dead_code)] impl BrandPersonality { pub fn as_str(&self) -> &'static str { @@ -847,19 +840,6 @@ impl BrandPersonality { } } - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "professional" => BrandPersonality::Professional, - "friendly" => BrandPersonality::Friendly, - "playful" => BrandPersonality::Playful, - "luxurious" => BrandPersonality::Luxurious, - "minimalist" => BrandPersonality::Minimalist, - "bold" => BrandPersonality::Bold, - "elegant" => BrandPersonality::Elegant, - _ => BrandPersonality::Professional, - } - } - pub fn display_name(&self) -> &'static str { match self { BrandPersonality::Professional => "专业严谨", @@ -873,13 +853,31 @@ impl BrandPersonality { } } +impl std::str::FromStr for BrandPersonality { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "professional" => Ok(BrandPersonality::Professional), + "friendly" => Ok(BrandPersonality::Friendly), + "playful" => Ok(BrandPersonality::Playful), + "luxurious" => Ok(BrandPersonality::Luxurious), + "minimalist" => Ok(BrandPersonality::Minimalist), + "bold" => Ok(BrandPersonality::Bold), + "elegant" => Ok(BrandPersonality::Elegant), + _ => Ok(BrandPersonality::Professional), + } + } +} + /// 设计风格类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum DesignStyle { /// 极简 Minimal, /// 现代 + #[default] Modern, /// 经典 Classic, @@ -893,12 +891,6 @@ pub enum DesignStyle { Retro, } -impl Default for DesignStyle { - fn default() -> Self { - Self::Modern - } -} - #[allow(dead_code)] impl DesignStyle { pub fn as_str(&self) -> &'static str { @@ -913,19 +905,6 @@ impl DesignStyle { } } - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "minimal" => DesignStyle::Minimal, - "modern" => DesignStyle::Modern, - "classic" => DesignStyle::Classic, - "playful" => DesignStyle::Playful, - "corporate" => DesignStyle::Corporate, - "artistic" => DesignStyle::Artistic, - "retro" => DesignStyle::Retro, - _ => DesignStyle::Modern, - } - } - pub fn display_name(&self) -> &'static str { match self { DesignStyle::Minimal => "极简", @@ -939,6 +918,23 @@ impl DesignStyle { } } +impl std::str::FromStr for DesignStyle { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "minimal" => Ok(DesignStyle::Minimal), + "modern" => Ok(DesignStyle::Modern), + "classic" => Ok(DesignStyle::Classic), + "playful" => Ok(DesignStyle::Playful), + "corporate" => Ok(DesignStyle::Corporate), + "artistic" => Ok(DesignStyle::Artistic), + "retro" => Ok(DesignStyle::Retro), + _ => Ok(DesignStyle::Modern), + } + } +} + /// 配色方案 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/core/src/session/sticky_manager.rs b/src-tauri/crates/core/src/session/sticky_manager.rs index 4aafe2799..d5c0a845b 100644 --- a/src-tauri/crates/core/src/session/sticky_manager.rs +++ b/src-tauri/crates/core/src/session/sticky_manager.rs @@ -136,12 +136,10 @@ impl StickySessionManager { let total = sorted_accounts.len(); // 模式 A: 粘性会话处理 - if !force_rotate && session_id.is_some() && config.mode != SchedulingMode::PerformanceFirst - { - let sid = session_id.unwrap(); - - // 检查会话是否已绑定账号 - if let Some(bound_id) = self.get_bound_account(sid) { + if !force_rotate && config.mode != SchedulingMode::PerformanceFirst { + if let Some(sid) = session_id { + // 检查会话是否已绑定账号 + if let Some(bound_id) = self.get_bound_account(sid) { // 找到绑定的账号 if let Some(bound_account) = sorted_accounts.iter().find(|a| a.account_id == bound_id) @@ -170,6 +168,7 @@ impl StickySessionManager { // 绑定的账号不存在,解绑 self.unbind_session(sid); } + } } } diff --git a/src-tauri/crates/core/src/tray_state.rs b/src-tauri/crates/core/src/tray_state.rs index 74069a852..728ee0f58 100644 --- a/src-tauri/crates/core/src/tray_state.rs +++ b/src-tauri/crates/core/src/tray_state.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// 托盘图标状态枚举 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum TrayIconStatus { /// 正常运行(绿色)- 服务器运行且凭证健康 @@ -15,15 +15,10 @@ pub enum TrayIconStatus { /// 错误状态(红色)- 服务器停止或所有凭证无效 Error, /// 停止状态(灰色)- 服务器未启动 + #[default] Stopped, } -impl Default for TrayIconStatus { - fn default() -> Self { - Self::Stopped - } -} - /// 凭证健康状态 #[derive(Debug, Clone, Default)] pub struct CredentialHealth { diff --git a/src-tauri/crates/services/src/content_creator/progress_store.rs b/src-tauri/crates/services/src/content_creator/progress_store.rs index 75f254e91..8eae78699 100644 --- a/src-tauri/crates/services/src/content_creator/progress_store.rs +++ b/src-tauri/crates/services/src/content_creator/progress_store.rs @@ -24,6 +24,7 @@ impl ProgressStore { conn.execute( "CREATE TABLE IF NOT EXISTS workflow_progress ( workflow_id TEXT PRIMARY KEY, + content_id TEXT NOT NULL, theme TEXT NOT NULL, mode TEXT NOT NULL, steps_json TEXT NOT NULL, @@ -40,6 +41,11 @@ impl ProgressStore { [], )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_workflow_content_id ON workflow_progress(content_id)", + [], + )?; + info!("进度存储初始化完成"); Ok(Self { @@ -56,11 +62,12 @@ impl ProgressStore { let mode_str = serde_json::to_string(&workflow.mode)?; conn.execute( - "INSERT OR REPLACE INTO workflow_progress - (workflow_id, theme, mode, steps_json, current_step_index, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT OR REPLACE INTO workflow_progress + (workflow_id, content_id, theme, mode, steps_json, current_step_index, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ workflow.id, + workflow.content_id, theme_str, mode_str, steps_json, @@ -79,21 +86,23 @@ impl ProgressStore { let conn = self.conn.lock().await; let mut stmt = conn.prepare( - "SELECT workflow_id, theme, mode, steps_json, current_step_index, created_at, updated_at + "SELECT workflow_id, content_id, theme, mode, steps_json, current_step_index, created_at, updated_at FROM workflow_progress WHERE workflow_id = ?1", )?; let result = stmt.query_row(params![workflow_id], |row| { let workflow_id: String = row.get(0)?; - let theme_str: String = row.get(1)?; - let mode_str: String = row.get(2)?; - let steps_json: String = row.get(3)?; - let current_step_index: i32 = row.get(4)?; - let created_at: i64 = row.get(5)?; - let updated_at: i64 = row.get(6)?; + let content_id: String = row.get(1)?; + let theme_str: String = row.get(2)?; + let mode_str: String = row.get(3)?; + let steps_json: String = row.get(4)?; + let current_step_index: i32 = row.get(5)?; + let created_at: i64 = row.get(6)?; + let updated_at: i64 = row.get(7)?; Ok(WorkflowProgress { workflow_id, + content_id, theme: serde_json::from_str(&theme_str).unwrap_or_default(), mode: serde_json::from_str(&mode_str).unwrap_or_default(), steps_json, @@ -108,6 +117,57 @@ impl ProgressStore { let steps: Vec = serde_json::from_str(&progress.steps_json)?; Ok(Some(WorkflowState { id: progress.workflow_id, + content_id: progress.content_id, + theme: progress.theme, + mode: progress.mode, + steps, + current_step_index: progress.current_step_index as usize, + created_at: progress.created_at, + updated_at: progress.updated_at, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// 根据 content_id 加载工作流进度 + pub async fn load_by_content_id(&self, content_id: &str) -> Result> { + let conn = self.conn.lock().await; + + let mut stmt = conn.prepare( + "SELECT workflow_id, content_id, theme, mode, steps_json, current_step_index, created_at, updated_at + FROM workflow_progress WHERE content_id = ?1 ORDER BY updated_at DESC LIMIT 1", + )?; + + let result = stmt.query_row(params![content_id], |row| { + let workflow_id: String = row.get(0)?; + let content_id: String = row.get(1)?; + let theme_str: String = row.get(2)?; + let mode_str: String = row.get(3)?; + let steps_json: String = row.get(4)?; + let current_step_index: i32 = row.get(5)?; + let created_at: i64 = row.get(6)?; + let updated_at: i64 = row.get(7)?; + + Ok(WorkflowProgress { + workflow_id, + content_id, + theme: serde_json::from_str(&theme_str).unwrap_or_default(), + mode: serde_json::from_str(&mode_str).unwrap_or_default(), + steps_json, + current_step_index, + created_at, + updated_at, + }) + }); + + match result { + Ok(progress) => { + let steps: Vec = serde_json::from_str(&progress.steps_json)?; + Ok(Some(WorkflowState { + id: progress.workflow_id, + content_id: progress.content_id, theme: progress.theme, mode: progress.mode, steps, @@ -137,21 +197,23 @@ impl ProgressStore { let conn = self.conn.lock().await; let mut stmt = conn.prepare( - "SELECT workflow_id, theme, mode, steps_json, current_step_index, created_at, updated_at + "SELECT workflow_id, content_id, theme, mode, steps_json, current_step_index, created_at, updated_at FROM workflow_progress ORDER BY updated_at DESC LIMIT ?1", )?; let rows = stmt.query_map(params![limit as i32], |row| { let workflow_id: String = row.get(0)?; - let theme_str: String = row.get(1)?; - let mode_str: String = row.get(2)?; - let steps_json: String = row.get(3)?; - let current_step_index: i32 = row.get(4)?; - let created_at: i64 = row.get(5)?; - let updated_at: i64 = row.get(6)?; + let content_id: String = row.get(1)?; + let theme_str: String = row.get(2)?; + let mode_str: String = row.get(3)?; + let steps_json: String = row.get(4)?; + let current_step_index: i32 = row.get(5)?; + let created_at: i64 = row.get(6)?; + let updated_at: i64 = row.get(7)?; Ok(WorkflowProgress { workflow_id, + content_id, theme: serde_json::from_str(&theme_str).unwrap_or_default(), mode: serde_json::from_str(&mode_str).unwrap_or_default(), steps_json, diff --git a/src-tauri/crates/services/src/content_creator/types.rs b/src-tauri/crates/services/src/content_creator/types.rs index 235dcac80..e3bd9b2bd 100644 --- a/src-tauri/crates/services/src/content_creator/types.rs +++ b/src-tauri/crates/services/src/content_creator/types.rs @@ -236,6 +236,7 @@ pub struct WorkflowStep { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowState { pub id: String, + pub content_id: String, pub theme: ThemeType, pub mode: CreationMode, pub steps: Vec, @@ -248,6 +249,7 @@ pub struct WorkflowState { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowProgress { pub workflow_id: String, + pub content_id: String, pub theme: ThemeType, pub mode: CreationMode, pub steps_json: String, diff --git a/src-tauri/crates/services/src/content_creator/workflow_service.rs b/src-tauri/crates/services/src/content_creator/workflow_service.rs index 4c45fe70d..0d6c89966 100644 --- a/src-tauri/crates/services/src/content_creator/workflow_service.rs +++ b/src-tauri/crates/services/src/content_creator/workflow_service.rs @@ -27,6 +27,7 @@ impl WorkflowService { /// 创建新工作流 pub async fn create_workflow( &self, + content_id: String, theme: ThemeType, mode: CreationMode, ) -> Result { @@ -38,6 +39,7 @@ impl WorkflowService { let workflow = WorkflowState { id: workflow_id.clone(), + content_id, theme, mode, steps, @@ -60,6 +62,15 @@ impl WorkflowService { workflows.get(workflow_id).cloned() } + /// 根据 content_id 获取工作流 + pub async fn get_workflow_by_content(&self, content_id: &str) -> Option { + let workflows = self.workflows.read().await; + workflows + .values() + .find(|w| w.content_id == content_id) + .cloned() + } + /// 更新工作流 pub async fn update_workflow(&self, workflow: WorkflowState) -> Result<()> { let mut workflows = self.workflows.write().await; 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 index 8e132e840..81a70407b 100644 --- a/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md +++ b/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md @@ -4,10 +4,13 @@ description: 生成可直接发布的社媒成稿(默认公众号风格)并 allowed-tools: social_generate_cover_image, search_query argument-hint: 输入主题、平台(如公众号/小红书)、目标受众、语气、字数、转化目标和已知素材。 when-to-use: 用户需要“社媒文章 + 封面图”一体化输出,且希望直接复制发布。 -version: 1.2.0 +version: 1.3.1 execution-mode: prompt +steps-json: '[{"id":"research","name":"阅读项目素材并检索资料","prompt":"当前任务是信息收集阶段。\n1. 仔细阅读用户提供的全部上下文素材([生效上下文]、[历史内容]、链接等)。\n2. 如果上下文不足,使用 search_query 进行 2-4 次检索,覆盖核心主题、目标受众关注点、最新案例。\n3. 将收集到的信息整理为结构化素材摘要,格式:【主题定位】【关键信息点】【目标受众洞察】【可用素材来源】。\n4. 不要撰写正文,只输出素材摘要,供后续步骤使用。","execution_mode":"prompt"},{"id":"write","name":"撰写社媒主稿","prompt":"当前任务是文稿撰写阶段。\n基于前序步骤提供的素材摘要,按照 skill 中的文案生成规则撰写完整社媒文章:\n1. 输出完整文章:标题、导语、正文(分节)、结尾 CTA。\n2. 严格匹配用户指定平台语气,未指定时默认公众号长文风格。\n3. 不要调用 social_generate_cover_image,不要输出 write_file,只输出文章正文 Markdown。\n4. 在文章末尾另起一行,输出封面图提示词建议:【封面图提示词建议】xxx(用于下一步生成)。","execution_mode":"prompt"},{"id":"cover","name":"生成封面图并输出主稿","prompt":"当前任务是封面图生成与最终落盘阶段。\n基于前序步骤提供的完整文稿:\n1. 提取文章主题与核心视觉元素,调用 social_generate_cover_image 生成 1 张封面图(尺寸 1024x1024)。\n2. 将文章与封面图整合,严格按照 skill 规定的 write_file 格式输出最终主稿文件(配图说明不得放入 write_file 内)。\n3. 如封面图生成失败,使用 【img:multimodel:{你准备的封面图提示词}】 作为占位 URL(例如 ![封面图](【img:multimodel:科技感实验室,蓝色调】)),继续完成 write_file 输出。\n4. 在 write_file 之后另起一行输出配图说明(提示词/尺寸/状态/备注),不放在文件内容里。","execution_mode":"prompt"}]' --- + + 你是资深社媒内容策划与文案编辑,请根据用户输入生成高质量社媒文章,并调用工具生成封面图。 ## 工作目标 @@ -63,10 +66,10 @@ execution-mode: prompt 请严格按以下格式输出,且最终结果必须在一个 `` 块内: ```markdown - + # {标题} -![封面图]({图片URL或占位符}) +![封面图]({图片URL,失败时用 【img:multimodel:{封面图提示词}】 作为占位}) ## 导语 {导语内容} @@ -77,22 +80,26 @@ execution-mode: prompt ## 结尾 {结尾与行动号召} -## 配图说明 -- 提示词:{用于生成封面图的 prompt} -- 尺寸:{size} -- 状态:{成功/失败} -- 备注:{失败时给出一句重试建议;成功时可留空} - ## 参考信息 - 来源:{如有检索,写来源名称或站点}(日期:{YYYY-MM-DD}) - 来源:{可选,最多 3 条} ``` +在 `` 之后,另起一行输出配图元数据(不进入文件): + +``` +**配图说明** +- 提示词:{用于生成封面图的 prompt} +- 尺寸:{size} +- 状态:{成功/失败} +- 备注:{失败时给出一句重试建议;成功时可留空} +``` + 补充约束: - 只输出一个主稿 ``,不要输出多个版本文件。 - 不要在 `` 之外重复正文全文。 -- `` 内只能放“最终可发布主稿”,不要混入发布包 JSON 或过程日志。 +- `` 内只能放”最终可发布主稿”,配图元数据放在 `` 外部输出。 ## 质量检查清单 diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index a00f79f87..53374b559 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -79,6 +79,8 @@ pub struct AppStates { pub recording_service: RecordingServiceState, pub mcp_manager: McpManagerState, pub heartbeat_service: HeartbeatServiceState, + pub workflow_service: Arc>, + pub progress_store: Arc>, // 用于 setup hook 的共享实例 pub shared_stats: Arc>, pub shared_tokens: Arc>, @@ -287,6 +289,16 @@ pub fn init_states(config: &Config) -> Result { heartbeat_service.set_db(db.clone()); let heartbeat_service_state = HeartbeatServiceState(Arc::new(RwLock::new(heartbeat_service))); + // 初始化工作流服务 + let workflow_service = proxycast_services::content_creator::WorkflowService::new(); + let workflow_service_state = Arc::new(RwLock::new(workflow_service)); + + // 初始化进度存储 + let db_path = database::get_db_path().map_err(|e| format!("获取数据库路径失败: {e}"))?; + let progress_store = proxycast_services::content_creator::ProgressStore::new(db_path) + .map_err(|e| format!("ProgressStore 初始化失败: {e}"))?; + let progress_store_state = Arc::new(RwLock::new(progress_store)); + Ok(AppStates { state, logs, @@ -317,6 +329,8 @@ pub fn init_states(config: &Config) -> Result { recording_service: recording_service_state, mcp_manager: mcp_manager_state, heartbeat_service: heartbeat_service_state, + workflow_service: workflow_service_state, + progress_store: progress_store_state, shared_stats, shared_tokens, shared_logger, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 9501d6201..96c1339d0 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -79,6 +79,8 @@ pub fn run() { recording_service, mcp_manager: mcp_manager_state, heartbeat_service: heartbeat_service_state, + workflow_service, + progress_store, shared_stats, shared_tokens, shared_logger, @@ -89,6 +91,10 @@ pub fn run() { let logs_clone = logs.clone(); let db_clone = db.clone(); let pool_service_clone = provider_pool_service_state.0.clone(); + let api_key_provider_service_clone = api_key_provider_service_state.0.clone(); + let connect_state_clone = connect_state.0.clone(); + let model_registry_clone = model_registry_state.clone(); + let skill_service_clone = skill_service_state.0.clone(); let token_cache_clone = token_cache_service_state.0.clone(); let shared_stats_clone = shared_stats.clone(); let shared_tokens_clone = shared_tokens.clone(); @@ -159,6 +165,8 @@ pub fn run() { .manage(recording_service) .manage(mcp_manager_state) .manage(heartbeat_service_state) + .manage(workflow_service) + .manage(progress_store) .manage(proxycast_gateway::telegram::TelegramGatewayState::default()) .manage(proxycast_gateway::discord::DiscordGatewayState::default()) .manage(proxycast_gateway::feishu::FeishuGatewayState::default()) @@ -266,6 +274,39 @@ pub fn run() { tracing::info!("[启动] PluginManager 任务事件发射器已设置"); } + #[cfg(debug_assertions)] + { + let server_state = state_clone.clone(); + let logs = logs_clone.clone(); + let db = Some(db_clone.clone()); + let pool_service = pool_service_clone.clone(); + let api_key_provider_service = api_key_provider_service_clone.clone(); + let connect_state = connect_state_clone.clone(); + let model_registry = model_registry_clone.clone(); + let skill_service = skill_service_clone.clone(); + let shared_stats = shared_stats_clone.clone(); + + tauri::async_runtime::spawn(async move { + match crate::dev_bridge::DevBridgeServer::start( + server_state, + logs, + db, + pool_service, + api_key_provider_service, + connect_state, + model_registry, + skill_service, + shared_stats, + None, + ) + .await + { + Ok(()) => tracing::info!("[启动] Dev Bridge 已启动"), + Err(error) => tracing::error!("[启动] Dev Bridge 启动失败: {}", error), + } + }); + } + // 初始化截图对话模块 // _Requirements: 7.3_ { @@ -1205,6 +1246,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::aster_agent_cmd::social_generate_cover_image_cmd, commands::theme_context_cmd::aster_agent_theme_context_search, // Models config commands commands::models_cmd::get_models_config, @@ -1478,6 +1520,13 @@ pub fn run() { commands::content_cmd::content_delete, commands::content_cmd::content_reorder, commands::content_cmd::content_stats, + // Content Workflow commands + commands::content_workflow_cmd::content_workflow_create, + commands::content_workflow_cmd::content_workflow_get, + commands::content_workflow_cmd::content_workflow_get_by_content, + commands::content_workflow_cmd::content_workflow_advance, + commands::content_workflow_cmd::content_workflow_retry, + commands::content_workflow_cmd::content_workflow_cancel, // Novel Orchestrator commands commands::novel_cmd::novel_create_project, commands::novel_cmd::novel_update_settings, diff --git a/src-tauri/src/app/state.rs b/src-tauri/src/app/state.rs index ca7071111..e6d103f91 100644 --- a/src-tauri/src/app/state.rs +++ b/src-tauri/src/app/state.rs @@ -21,6 +21,7 @@ use crate::plugin; use crate::telemetry; use proxycast_core::config::{Config, ConfigManager}; use proxycast_services::api_key_provider_service::ApiKeyProviderService; +use proxycast_services::content_creator::{ProgressStore, WorkflowService}; use proxycast_services::context_memory_service::{ContextMemoryConfig, ContextMemoryService}; use proxycast_services::provider_pool_service::ProviderPoolService; use proxycast_services::skill_service::SkillService; @@ -61,6 +62,8 @@ pub struct ServiceStates { pub orchestrator: OrchestratorState, pub context_memory_service: ContextMemoryServiceState, pub tool_hooks_service: ToolHooksServiceState, + pub workflow_service: Arc>, + pub progress_store: Arc>, } /// 初始化所有服务状态 @@ -114,6 +117,15 @@ pub fn init_service_states() -> ServiceStates { let tool_hooks_service = ToolHooksService::new(context_memory_service_state.0.clone()); let tool_hooks_service_state = ToolHooksServiceState(Arc::new(tool_hooks_service)); + // Initialize WorkflowService + let workflow_service = WorkflowService::new(); + let workflow_service_state = Arc::new(RwLock::new(workflow_service)); + + // Initialize ProgressStore + let db_path = database::get_db_path().expect("Failed to get database path"); + let progress_store = ProgressStore::new(db_path).expect("Failed to initialize ProgressStore"); + let progress_store_state = Arc::new(RwLock::new(progress_store)); + ServiceStates { skill_service: skill_service_state, provider_pool_service: provider_pool_service_state, @@ -127,6 +139,8 @@ pub fn init_service_states() -> ServiceStates { orchestrator: orchestrator_state, context_memory_service: context_memory_service_state, tool_hooks_service: tool_hooks_service_state, + workflow_service: workflow_service_state, + progress_store: progress_store_state, } } diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 401c6b9a7..c6272e3b1 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -361,6 +361,9 @@ pub struct AsterChatRequest { /// 自动续写策略(用于文稿续写等场景) #[serde(default, alias = "autoContinue")] pub auto_continue: Option, + /// 前端传入的 System Prompt(可选,优先级低于项目上下文) + #[serde(default, alias = "systemPrompt")] + pub system_prompt: Option, } /// 自动续写参数 @@ -3308,10 +3311,11 @@ pub async fn aster_agent_chat_stream( }; // 2. 如果没有项目上下文,尝试从 session 读取 + // 3. 如果 session 也没有,使用前端传入的 system_prompt let resolved_prompt = if project_prompt.is_some() { project_prompt } else { - match session { + let session_prompt = match session { Some(session) => { tracing::debug!( "[AsterAgent] 找到 session,system_prompt: {:?}", @@ -3326,6 +3330,22 @@ pub async fn aster_agent_chat_stream( ); None } + }; + // fallback 到前端传入的 system_prompt + if session_prompt.is_some() { + session_prompt + } else if let Some(ref frontend_prompt) = request.system_prompt { + if !frontend_prompt.trim().is_empty() { + tracing::info!( + "[AsterAgent] 使用前端传入的 system_prompt, len={}", + frontend_prompt.len() + ); + Some(frontend_prompt.clone()) + } else { + None + } + } else { + None } }; @@ -4619,3 +4639,72 @@ async fn ensure_proxycast_mcp_servers_running( (success_count, fail_count) } + +/// 独立封面图生成命令:供前端直接调用,复用 social_generate_cover_image 工具的 HTTP 逻辑。 +/// 返回图片 URL 字符串,失败时返回错误信息。 +#[tauri::command] +pub async fn social_generate_cover_image_cmd( + config_manager: State<'_, GlobalConfigManagerState>, + prompt: String, + size: Option, +) -> Result { + if prompt.trim().is_empty() { + return Err("prompt 不能为空".to_string()); + } + let runtime_config = config_manager.config(); + let server_host = + SocialGenerateCoverImageTool::normalize_server_host(&runtime_config.server.host); + let size = size + .as_deref() + .filter(|s| !s.trim().is_empty()) + .or(runtime_config.image_gen.default_size.as_deref()) + .unwrap_or(SOCIAL_IMAGE_DEFAULT_SIZE) + .to_string(); + let endpoint = format!( + "http://{}:{}/v1/images/generations", + server_host, runtime_config.server.port + ); + let request_body = serde_json::json!({ + "prompt": prompt.trim(), + "model": SOCIAL_IMAGE_DEFAULT_MODEL, + "n": 1, + "size": size, + "response_format": "url" + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(180)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + let response = client + .post(&endpoint) + .header( + "Authorization", + format!("Bearer {}", runtime_config.server.api_key), + ) + .json(&request_body) + .send() + .await + .map_err(|e| format!("调用图像接口失败: {e}"))?; + + let status = response.status(); + let response_body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("图像接口响应解析失败: {e}"))?; + + if !status.is_success() { + let msg = response_body + .get("error") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("图像生成失败"); + return Err(msg.to_string()); + } + + let (image_url, _b64, _revised) = + SocialGenerateCoverImageTool::extract_first_image_payload(&response_body).map_err(|e| e)?; + + image_url.ok_or_else(|| "接口返回中未找到 image_url".to_string()) +} diff --git a/src-tauri/src/commands/content_cmd.rs b/src-tauri/src/commands/content_cmd.rs index 93e204dcf..fa2d02a4d 100644 --- a/src-tauri/src/commands/content_cmd.rs +++ b/src-tauri/src/commands/content_cmd.rs @@ -10,7 +10,7 @@ use crate::database::DbConnection; use serde::{Deserialize, Serialize}; use tauri::State; -const THEME_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1"; +pub(crate) const THEME_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1"; /// 内容列表项(用于前端展示) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -103,7 +103,7 @@ fn is_valid_topic_branch_status(status: &str) -> bool { matches!(status, "in_progress" | "pending" | "merged" | "candidate") } -fn parse_theme_workbench_document_state( +pub(crate) fn parse_theme_workbench_document_state( content_id: &str, metadata: Option<&serde_json::Value>, ) -> Option { diff --git a/src-tauri/src/commands/content_workflow_cmd.rs b/src-tauri/src/commands/content_workflow_cmd.rs new file mode 100644 index 000000000..ebd046cbe --- /dev/null +++ b/src-tauri/src/commands/content_workflow_cmd.rs @@ -0,0 +1,211 @@ +//! 内容创作工作流命令 +//! +//! 暴露工作流服务给前端 + +use crate::app::bootstrap::AppStates; +use anyhow::Result; +use proxycast_services::content_creator::{CreationMode, StepResult, ThemeType, WorkflowState}; +use tauri::State; +use tracing::{error, info}; + +/// 创建工作流 +#[tauri::command] +pub async fn content_workflow_create( + content_id: String, + theme: String, + mode: String, + state: State<'_, AppStates>, +) -> Result { + info!( + "创建工作流: content_id={}, theme={}, mode={}", + content_id, theme, mode + ); + + // 解析主题和模式 + let theme_type: ThemeType = serde_json::from_value(serde_json::json!(theme)) + .map_err(|e| format!("无效的主题类型: {}", e))?; + let creation_mode: CreationMode = serde_json::from_value(serde_json::json!(mode)) + .map_err(|e| format!("无效的创作模式: {}", e))?; + + // 获取服务 + let workflow_service = state.workflow_service.read().await; + let progress_store = state.progress_store.read().await; + + // 创建工作流 + let workflow = workflow_service + .create_workflow(content_id, theme_type, creation_mode) + .await + .map_err(|e| { + error!("创建工作流失败: {}", e); + format!("创建工作流失败: {}", e) + })?; + + // 持久化 + progress_store.save_progress(&workflow).await.map_err(|e| { + error!("保存工作流进度失败: {}", e); + format!("保存工作流进度失败: {}", e) + })?; + + Ok(workflow) +} + +/// 获取工作流 +#[tauri::command] +pub async fn content_workflow_get( + workflow_id: String, + state: State<'_, AppStates>, +) -> Result, String> { + info!("获取工作流: workflow_id={}", workflow_id); + + let workflow_service = state.workflow_service.read().await; + let progress_store = state.progress_store.read().await; + + // 先从内存缓存获取 + if let Some(workflow) = workflow_service.get_workflow(&workflow_id).await { + return Ok(Some(workflow)); + } + + // 从数据库加载 + let workflow = progress_store + .load_progress(&workflow_id) + .await + .map_err(|e| { + error!("加载工作流进度失败: {}", e); + format!("加载工作流进度失败: {}", e) + })?; + + // 如果从数据库加载成功,更新内存缓存 + if let Some(ref wf) = workflow { + workflow_service.update_workflow(wf.clone()).await.ok(); + } + + Ok(workflow) +} + +/// 根据 content_id 获取工作流 +#[tauri::command] +pub async fn content_workflow_get_by_content( + content_id: String, + state: State<'_, AppStates>, +) -> Result, String> { + info!("根据 content_id 获取工作流: content_id={}", content_id); + + let workflow_service = state.workflow_service.read().await; + let progress_store = state.progress_store.read().await; + + // 先从内存缓存获取 + if let Some(workflow) = workflow_service.get_workflow_by_content(&content_id).await { + return Ok(Some(workflow)); + } + + // 从数据库加载 + let workflow = progress_store + .load_by_content_id(&content_id) + .await + .map_err(|e| { + error!("根据 content_id 加载工作流进度失败: {}", e); + format!("根据 content_id 加载工作流进度失败: {}", e) + })?; + + // 如果从数据库加载成功,更新内存缓存 + if let Some(ref wf) = workflow { + workflow_service.update_workflow(wf.clone()).await.ok(); + } + + Ok(workflow) +} + +/// 推进工作流(完成当前步骤) +#[tauri::command] +pub async fn content_workflow_advance( + workflow_id: String, + step_result: StepResult, + state: State<'_, AppStates>, +) -> Result { + info!("推进工作流: workflow_id={}", workflow_id); + + let workflow_service = state.workflow_service.read().await; + let progress_store = state.progress_store.read().await; + + // 完成当前步骤 + let workflow = workflow_service + .complete_step(&workflow_id, step_result) + .await + .map_err(|e| { + error!("完成步骤失败: {}", e); + format!("完成步骤失败: {}", e) + })?; + + // 持久化 + progress_store.save_progress(&workflow).await.map_err(|e| { + error!("保存工作流进度失败: {}", e); + format!("保存工作流进度失败: {}", e) + })?; + + Ok(workflow) +} + +/// 重试失败的步骤 +#[tauri::command] +pub async fn content_workflow_retry( + workflow_id: String, + state: State<'_, AppStates>, +) -> Result { + info!("重试工作流步骤: workflow_id={}", workflow_id); + + let workflow_service = state.workflow_service.read().await; + let progress_store = state.progress_store.read().await; + + // 重做当前步骤 + let mut workflow = workflow_service + .get_workflow(&workflow_id) + .await + .ok_or_else(|| format!("工作流不存在: {}", workflow_id))?; + + let current_index = workflow.current_step_index; + if current_index < workflow.steps.len() { + workflow.steps[current_index].status = + proxycast_services::content_creator::StepStatus::Pending; + workflow.steps[current_index].result = None; + workflow.updated_at = chrono::Utc::now().timestamp_millis(); + + // 更新工作流 + workflow_service + .update_workflow(workflow.clone()) + .await + .map_err(|e| { + error!("更新工作流失败: {}", e); + format!("更新工作流失败: {}", e) + })?; + + // 持久化 + progress_store.save_progress(&workflow).await.map_err(|e| { + error!("保存工作流进度失败: {}", e); + format!("保存工作流进度失败: {}", e) + })?; + } + + Ok(workflow) +} + +/// 取消工作流 +#[tauri::command] +pub async fn content_workflow_cancel( + workflow_id: String, + state: State<'_, AppStates>, +) -> Result<(), String> { + info!("取消工作流: workflow_id={}", workflow_id); + + let progress_store = state.progress_store.read().await; + + // 从数据库删除 + progress_store + .delete_progress(&workflow_id) + .await + .map_err(|e| { + error!("删除工作流进度失败: {}", e); + format!("删除工作流进度失败: {}", e) + })?; + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 950858e76..00aa45991 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod config_cmd; pub mod connect_cmd; pub mod connection_cmd; pub mod content_cmd; +pub mod content_workflow_cmd; pub mod context_memory; pub mod document_import_cmd; pub mod ecommerce_review_reply_cmd; diff --git a/src-tauri/src/commands/skill_exec_cmd.rs b/src-tauri/src/commands/skill_exec_cmd.rs index 2bae02b76..2920b6b0c 100644 --- a/src-tauri/src/commands/skill_exec_cmd.rs +++ b/src-tauri/src/commands/skill_exec_cmd.rs @@ -286,15 +286,6 @@ fn normalize_social_markdown_contract(content: &str) -> String { 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 } diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index 559549139..4005abfd6 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -12,7 +12,7 @@ use axum::{ extract::State, http::{HeaderValue, Method}, response::{IntoResponse, Response}, - routing::post, + routing::{get, post}, Json, Router, }; #[cfg(debug_assertions)] @@ -24,7 +24,15 @@ use tokio::sync::RwLock; #[cfg(debug_assertions)] use tower_http::cors::CorsLayer; -use proxycast_server::AppState; +#[cfg(debug_assertions)] +use crate::{app, database::DbConnection}; +#[cfg(debug_assertions)] +use proxycast_infra::telemetry::StatsAggregator; +#[cfg(debug_assertions)] +use proxycast_services::{ + api_key_provider_service::ApiKeyProviderService, model_registry_service::ModelRegistryService, + provider_pool_service::ProviderPoolService, skill_service::SkillService, +}; #[cfg(debug_assertions)] #[derive(Debug, Deserialize)] @@ -41,6 +49,20 @@ pub struct InvokeResponse { pub error: Option, } +#[cfg(debug_assertions)] +#[derive(Clone)] +pub struct DevBridgeState { + pub server: app::AppState, + pub logs: app::LogState, + pub db: Option, + pub pool_service: Arc, + pub api_key_provider_service: Arc, + pub connect_state: Arc>>, + pub model_registry: Arc>>, + pub skill_service: Arc, + pub shared_stats: Arc>, +} + /// 开发桥接服务器配置 #[cfg(debug_assertions)] pub struct DevBridgeConfig { @@ -73,10 +95,29 @@ impl DevBridgeServer { /// /// 服务器会在后台持续运行,直到应用退出。 pub async fn start( - app_state: Arc>, + server: app::AppState, + logs: app::LogState, + db: Option, + pool_service: Arc, + api_key_provider_service: Arc, + connect_state: Arc>>, + model_registry: Arc>>, + skill_service: Arc, + shared_stats: Arc>, config: Option, ) -> Result<(), Box> { let config = config.unwrap_or_default(); + let bridge_state = DevBridgeState { + server, + logs, + db, + pool_service, + api_key_provider_service, + connect_state, + model_registry, + skill_service, + shared_stats, + }; let allowed_origins = vec![ HeaderValue::from_static("http://localhost:1420"), @@ -87,7 +128,7 @@ impl DevBridgeServer { let app = Router::new() .route("/invoke", post(invoke_command)) - .route("/health", post(health_check)) + .route("/health", get(health_check).post(health_check)) .layer( // CORS 配置 - 允许本地开发前端访问 CorsLayer::new() @@ -95,7 +136,7 @@ impl DevBridgeServer { .allow_methods([Method::POST, Method::GET, Method::OPTIONS]) .allow_headers([axum::http::header::CONTENT_TYPE]), ) - .with_state(app_state); + .with_state(bridge_state); let addr = format!("{}:{}", config.host, config.port); let listener = match tokio::net::TcpListener::bind(&addr).await { @@ -111,7 +152,9 @@ impl DevBridgeServer { // 直接运行服务器(不使用 graceful_shutdown) // 服务器将持续运行直到应用退出 tokio::spawn(async move { - axum::serve(listener, app).await.ok(); + if let Err(error) = axum::serve(listener, app).await { + tracing::error!("[DevBridge] 运行失败: {}", error); + } }); Ok(()) @@ -120,13 +163,11 @@ impl DevBridgeServer { #[cfg(debug_assertions)] async fn invoke_command( - State(state): State>>, + State(state): State, Json(req): Json, ) -> Response { - // 获取 AppState 的读锁 - let state_ref = state.read().await; // 调用命令分发器 - match dispatcher::handle_command(&state_ref, &req.cmd, req.args).await { + match dispatcher::handle_command(&state, &req.cmd, req.args).await { Ok(result) => Json(InvokeResponse { result: Some(result), error: None, diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 08ce7c3df..8f9203fbf 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -2,12 +2,32 @@ //! //! 将 HTTP 请求路由到现有的 Tauri 命令函数。 -use proxycast_server::AppState; +use crate::commands::content_cmd::{ + parse_theme_workbench_document_state, ContentDetail, ContentListItem, + CreateContentRequest as BridgeCreateContentRequest, + ListContentRequest as BridgeListContentRequest, ThemeWorkbenchDocumentState, + UpdateContentRequest as BridgeUpdateContentRequest, +}; +use crate::commands::workspace_cmd::{ + CreateWorkspaceRequest, UpdateWorkspaceRequest, WorkspaceEnsureResult, WorkspaceListItem, +}; +use crate::content::{ + ContentCreateRequest, ContentListQuery, ContentManager, ContentStatus, ContentUpdateRequest, +}; +use crate::dev_bridge::DevBridgeState; +use crate::services::workspace_health_service::{ + ensure_workspace_ready_with_auto_relocate, ensure_workspace_root_ready, +}; +use crate::workspace::{WorkspaceManager, WorkspaceType, WorkspaceUpdate}; +use proxycast_memory::{MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory}; use proxycast_server_utils::load_model_registry_provider_ids_from_resources; +use rusqlite::{params_from_iter, types::Value}; +use serde::de::DeserializeOwned; use serde_json::Value as JsonValue; +use std::path::PathBuf; fn load_model_registry_provider_ids_from_db( - state: &AppState, + state: &DevBridgeState, ) -> Result, Box> { let Some(db) = &state.db else { return Ok(vec![]); @@ -27,11 +47,247 @@ fn load_model_registry_provider_ids_from_db( Ok(provider_ids) } +fn get_db( + state: &DevBridgeState, +) -> Result<&crate::database::DbConnection, Box> { + state + .db + .as_ref() + .ok_or_else(|| "Database not initialized".into()) +} + +fn get_string_arg( + args: &JsonValue, + primary: &str, + secondary: &str, +) -> Result> { + args.get(primary) + .or_else(|| args.get(secondary)) + .and_then(|value| value.as_str()) + .map(ToString::to_string) + .ok_or_else(|| format!("缺少参数: {primary}/{secondary}").into()) +} + +fn get_optional_bool_arg(args: &JsonValue, primary: &str, secondary: &str) -> Option { + args.get(primary) + .or_else(|| args.get(secondary)) + .and_then(|value| value.as_bool()) +} + +fn parse_nested_arg( + args: &JsonValue, + key: &str, +) -> Result> { + let payload = args.get(key).cloned().unwrap_or_else(|| args.clone()); + Ok(serde_json::from_value(payload)?) +} + +fn parse_optional_nested_arg( + args: &JsonValue, + key: &str, +) -> Result, Box> { + match args.get(key).cloned() { + Some(value) if value.is_null() => Ok(None), + Some(value) => Ok(Some(serde_json::from_value(value)?)), + None => Ok(None), + } +} + +fn get_workspace_projects_root_dir() -> Result { + let home_dir = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; + let root_dir = home_dir.join(".proxycast").join("projects"); + + std::fs::create_dir_all(&root_dir).map_err(|e| format!("创建 workspace 目录失败: {e}"))?; + + Ok(root_dir) +} + +fn mask_api_key_for_display(key: &str) -> String { + let chars: Vec = key.chars().collect(); + if chars.len() <= 12 { + "****".to_string() + } else { + let prefix: String = chars[..6].iter().collect(); + let suffix: String = chars[chars.len() - 4..].iter().collect(); + format!("{prefix}****{suffix}") + } +} + +fn api_key_provider_with_keys_to_display( + provider_with_keys: &crate::database::dao::api_key_provider::ProviderWithKeys, + service: &proxycast_services::api_key_provider_service::ApiKeyProviderService, +) -> crate::commands::api_key_provider_cmd::ProviderWithKeysDisplay { + let api_keys = provider_with_keys + .api_keys + .iter() + .map(|key| { + let masked = match service.decrypt_api_key(&key.api_key_encrypted) { + Ok(decrypted) => mask_api_key_for_display(&decrypted), + Err(_) => "****".to_string(), + }; + + crate::commands::api_key_provider_cmd::ApiKeyDisplay { + id: key.id.clone(), + provider_id: key.provider_id.clone(), + api_key_masked: masked, + alias: key.alias.clone(), + enabled: key.enabled, + usage_count: key.usage_count, + error_count: key.error_count, + last_used_at: key.last_used_at.map(|value| value.to_rfc3339()), + created_at: key.created_at.to_rfc3339(), + } + }) + .collect(); + + crate::commands::api_key_provider_cmd::ProviderWithKeysDisplay { + provider: crate::commands::api_key_provider_cmd::ProviderDisplay { + id: provider_with_keys.provider.id.clone(), + name: provider_with_keys.provider.name.clone(), + provider_type: provider_with_keys.provider.provider_type.to_string(), + api_host: provider_with_keys.provider.api_host.clone(), + is_system: provider_with_keys.provider.is_system, + group: provider_with_keys.provider.group.to_string(), + enabled: provider_with_keys.provider.enabled, + sort_order: provider_with_keys.provider.sort_order, + api_version: provider_with_keys.provider.api_version.clone(), + project: provider_with_keys.provider.project.clone(), + location: provider_with_keys.provider.location.clone(), + region: provider_with_keys.provider.region.clone(), + custom_models: provider_with_keys.provider.custom_models.clone(), + api_key_count: provider_with_keys.api_keys.len(), + created_at: provider_with_keys.provider.created_at.to_rfc3339(), + updated_at: provider_with_keys.provider.updated_at.to_rfc3339(), + }, + api_keys, + } +} + +fn parse_unified_memory_row(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let session_id: String = row.get(1)?; + let memory_type_json: String = row.get(2)?; + let category_json: String = row.get(3)?; + let title: String = row.get(4)?; + let content: String = row.get(5)?; + let summary: String = row.get(6)?; + let tags_json: String = row.get(7)?; + let confidence: f32 = row.get(8)?; + let importance: i64 = row.get(9)?; + let access_count: i64 = row.get(10)?; + let last_accessed_at: Option = row.get(11)?; + let source_json: String = row.get(12)?; + let created_at: i64 = row.get(13)?; + let updated_at: i64 = row.get(14)?; + let archived: i64 = row.get(15)?; + + let memory_type: MemoryType = serde_json::from_str(&memory_type_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let category: MemoryCategory = serde_json::from_str(&category_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let tags: Vec = serde_json::from_str(&tags_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let source: MemorySource = serde_json::from_str(&source_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + Ok(UnifiedMemory { + id, + session_id, + memory_type, + category, + title, + content, + summary, + tags, + metadata: MemoryMetadata { + confidence, + importance: importance.clamp(0, 10) as u8, + access_count: access_count.max(0) as u32, + last_accessed_at, + source, + embedding: None, + }, + created_at, + updated_at, + archived: archived != 0, + }) +} + +fn unified_memory_category_to_key(category: &MemoryCategory) -> &'static str { + match category { + MemoryCategory::Identity => "identity", + MemoryCategory::Context => "context", + MemoryCategory::Preference => "preference", + MemoryCategory::Experience => "experience", + MemoryCategory::Activity => "activity", + } +} + +fn ordered_unified_categories() -> [&'static str; 5] { + [ + "identity", + "context", + "preference", + "experience", + "activity", + ] +} + +fn normalize_unified_category_value(value: &str) -> Option<&'static str> { + if let Ok(category) = serde_json::from_str::(value) { + return Some(unified_memory_category_to_key(&category)); + } + + match value.trim_matches('"').to_lowercase().as_str() { + "identity" | "身份" => Some("identity"), + "context" | "情境" | "上下文" => Some("context"), + "preference" | "偏好" => Some("preference"), + "experience" | "经验" => Some("experience"), + "activity" | "活动" => Some("activity"), + _ => None, + } +} + +fn normalize_unified_sort_by(sort_by: Option<&str>) -> &'static str { + match sort_by.unwrap_or("updated_at") { + "created_at" => "created_at", + "importance" => "importance", + "access_count" => "access_count", + _ => "updated_at", + } +} + +fn normalize_unified_sort_order(order: Option<&str>) -> &'static str { + match order.unwrap_or("desc").to_lowercase().as_str() { + "asc" => "ASC", + _ => "DESC", + } +} + +fn sanitize_project_dir_name(name: &str) -> String { + let sanitized: String = name + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ if ch.is_control() => '_', + _ => ch, + }) + .collect(); + + let trimmed = sanitized.trim().trim_matches('.').to_string(); + if trimmed.is_empty() { + "未命名项目".to_string() + } else { + trimmed + } +} + /// 处理 HTTP 桥接命令请求 /// /// 将命令名和参数分发到对应的命令处理函数 pub async fn handle_command( - state: &AppState, + state: &DevBridgeState, cmd: &str, args: Option, ) -> Result> { @@ -53,52 +309,44 @@ pub async fn handle_command( } "get_default_provider" => { - let provider = state.default_provider.read().await.clone(); - // 直接返回字符串值,不是对象 + let default_provider_ref = { state.server.read().await.default_provider_ref.clone() }; + let provider = default_provider_ref.read().await.clone(); Ok(serde_json::json!(provider)) } "get_endpoint_providers" => { - let providers = state.endpoint_providers.read().await; - Ok(serde_json::to_value(&*providers)?) + let providers = { state.server.read().await.config.endpoint_providers.clone() }; + Ok(serde_json::to_value(providers)?) } // ========== P0 - 服务器状态 ========== "get_server_status" => { - // 解析 base_url 获取 host 和 port - let url_parts: Vec<&str> = state.base_url.split(':').collect(); - let host = url_parts.get(2).unwrap_or(&"127.0.0.1"); - let port = url_parts.get(3).and_then(|p| p.parse::().ok()).unwrap_or(3030); - - let status = serde_json::json!({ - "running": true, // HTTP 桥接可用说明服务器在运行 - "host": host, - "port": port, - "api_key": "***", // 不暴露真实 API key - }); - Ok(status) + let status = { state.server.read().await.status() }; + Ok(serde_json::to_value(status)?) } "get_server_diagnostics" => { - let (host, port) = if let Ok(url) = reqwest::Url::parse(&state.base_url) { + let (status, capability_routing, response_cache, request_dedup, idempotency) = { + let server = state.server.read().await; ( - url.host_str().unwrap_or("127.0.0.1").to_string(), - url.port_or_known_default().unwrap_or(3030), + server.status(), + server.capability_routing_metrics_store.snapshot(), + server.response_cache_store.clone(), + server.request_dedup_store.clone(), + server.idempotency_store.clone(), ) - } else { - ("127.0.0.1".to_string(), 3030) }; - let telemetry_summary = state.processor.stats.read().summary(None); + let telemetry_summary = state.shared_stats.read().summary(None); let diagnostics = proxycast_server::build_server_diagnostics( - true, - host, - port, + status.running, + status.host, + status.port, telemetry_summary, - state.capability_routing_metrics_store.snapshot(), - state.response_cache_store.as_ref(), - state.request_dedup_store.as_ref(), - state.idempotency_store.as_ref(), + capability_routing, + response_cache.as_ref(), + request_dedup.as_ref(), + idempotency.as_ref(), ); Ok(serde_json::to_value(diagnostics)?) } @@ -151,21 +399,8 @@ pub async fn handle_command( // ========== Provider Pool ========== "get_provider_pool_overview" => { - // 从数据库获取凭证池概览 if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| e.to_string())?; - let credentials = crate::database::dao::provider_pool::ProviderPoolDao::get_all(&conn) - .unwrap_or_default(); - - let overview: Vec = credentials - .into_iter() - .map(|cred| serde_json::json!({ - "uuid": cred.uuid, - "name": cred.name, - "provider_type": cred.provider_type, - "enabled": true, - })) - .collect(); + let overview = state.pool_service.get_overview(db)?; Ok(serde_json::to_value(overview)?) } else { Ok(serde_json::json!([])) @@ -173,12 +408,18 @@ pub async fn handle_command( } "get_api_key_providers" => { - // 从 API Key Provider 服务获取 if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| e.to_string())?; - let providers = crate::database::dao::api_key_provider::ApiKeyProviderDao::get_all_providers(&conn) - .unwrap_or_default(); - Ok(serde_json::to_value(providers)?) + let providers = state.api_key_provider_service.get_all_providers(db)?; + let items: Vec<_> = providers + .iter() + .map(|provider| { + api_key_provider_with_keys_to_display( + provider, + state.api_key_provider_service.as_ref(), + ) + }) + .collect(); + Ok(serde_json::to_value(items)?) } else { Ok(serde_json::json!([])) } @@ -202,6 +443,88 @@ pub async fn handle_command( } } + "get_provider_ui_state" => { + let args = args.unwrap_or_default(); + let key = get_string_arg(&args, "key", "key")?; + + if let Some(db) = &state.db { + let value = state.api_key_provider_service.get_ui_state(db, &key)?; + Ok(serde_json::to_value(value)?) + } else { + Ok(serde_json::Value::Null) + } + } + + "set_provider_ui_state" => { + let args = args.unwrap_or_default(); + let key = get_string_arg(&args, "key", "key")?; + let value = get_string_arg(&args, "value", "value")?; + + if let Some(db) = &state.db { + state + .api_key_provider_service + .set_ui_state(db, &key, &value) + .map_err(|e| format!("设置 Provider UI 状态失败: {e}"))?; + Ok(serde_json::json!({ "success": true })) + } else { + Err("Database not initialized".into()) + } + } + + "list_relay_providers" => { + let state_guard = state.connect_state.read().await; + if let Some(connect_state) = state_guard.as_ref() { + Ok(serde_json::to_value(connect_state.registry.list())?) + } else { + Ok(serde_json::json!([])) + } + } + + "refresh_relay_registry" => { + let state_guard = state.connect_state.read().await; + if let Some(connect_state) = state_guard.as_ref() { + connect_state + .registry + .load_from_remote() + .await + .map_err(|e| format!("刷新中转商注册表失败: {e}"))?; + Ok(serde_json::json!(connect_state.registry.len())) + } else { + Err("Connect 模块未初始化".into()) + } + } + + "get_skills_for_app" => { + let args = args.unwrap_or_default(); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?; + + if let Some(db) = &state.db { + let (repos, installed_states) = { + let conn = db.lock().map_err(|e| e.to_string())?; + let repos = crate::database::dao::skills::SkillDao::get_skill_repos(&conn) + .map_err(|e| e.to_string())?; + let installed_states = crate::database::dao::skills::SkillDao::get_skills(&conn) + .map_err(|e| e.to_string())?; + (repos, installed_states) + }; + + let skills = state + .skill_service + .list_skills(&app_type, &repos, &installed_states) + .await + .map_err(|e| e.to_string())?; + + Ok(serde_json::to_value(skills)?) + } else { + Ok(serde_json::json!([])) + } + } + "test_api" => { // 测试 API 连接 // 从 args 获取 provider @@ -243,6 +566,358 @@ pub async fn handle_command( } } + // ========== Workspace / Content ========== + "workspace_create" => { + let args = args.unwrap_or_default(); + let request: CreateWorkspaceRequest = parse_nested_arg(&args, "request")?; + + if request.root_path.contains("[object Promise]") { + return Err(format!( + "无效的 root_path: {}。请确保前端正确 await 了 Promise。", + request.root_path + ) + .into()); + } + + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let workspace_type = request + .workspace_type + .map(|workspace_type| WorkspaceType::parse(&workspace_type)) + .unwrap_or_default(); + let root_path = PathBuf::from(&request.root_path); + + ensure_workspace_root_ready(&root_path)?; + + let workspace = manager.create_with_type(request.name, root_path, workspace_type)?; + Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) + } + + "workspace_list" => { + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let workspaces = manager.list()?; + let items: Vec<_> = workspaces.into_iter().map(WorkspaceListItem::from).collect(); + Ok(serde_json::to_value(items)?) + } + + "workspace_get" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let workspace = manager.get(&id)?; + Ok(serde_json::to_value(workspace.map(WorkspaceListItem::from))?) + } + + "workspace_update" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let request: UpdateWorkspaceRequest = parse_nested_arg(&args, "request")?; + let manager = WorkspaceManager::new(get_db(state)?.clone()); + + let new_root_path = if let Some(ref path_str) = request.root_path { + let path = PathBuf::from(path_str); + let created = ensure_workspace_root_ready(&path)?; + if created { + tracing::warn!( + "[Workspace] 更新路径时检测到目录缺失,已自动创建: {}", + path.to_string_lossy() + ); + } + Some(path) + } else { + None + }; + + let updates = WorkspaceUpdate { + name: request.name, + settings: request.settings, + icon: request.icon, + color: request.color, + is_favorite: request.is_favorite, + is_archived: request.is_archived, + tags: request.tags, + root_path: new_root_path, + }; + + let workspace = manager.update(&id, updates)?; + Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) + } + + "workspace_delete" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let delete_directory = + get_optional_bool_arg(&args, "deleteDirectory", "delete_directory") + .unwrap_or(false); + let manager = WorkspaceManager::new(get_db(state)?.clone()); + + if delete_directory { + if let Some(workspace) = manager.get(&id)? { + let root_path = workspace.root_path; + if root_path.exists() && root_path.is_dir() { + std::fs::remove_dir_all(&root_path) + .map_err(|e| format!("删除目录失败: {e}"))?; + } + } + } + + Ok(serde_json::to_value(manager.delete(&id)?)?) + } + + "workspace_set_default" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let manager = WorkspaceManager::new(get_db(state)?.clone()); + manager.set_default(&id)?; + Ok(serde_json::json!(null)) + } + + "workspace_get_default" => { + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let workspace = manager.get_default()?; + Ok(serde_json::to_value(workspace.map(WorkspaceListItem::from))?) + } + + "workspace_ensure_ready" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let workspace = manager + .get(&id)? + .ok_or_else(|| format!("Workspace 不存在: {id}"))?; + let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; + let result = WorkspaceEnsureResult { + workspace_id: workspace.id, + root_path: ensured.root_path.to_string_lossy().to_string(), + existed: ensured.existed, + created: ensured.created, + repaired: ensured.repaired, + relocated: ensured.relocated, + previous_root_path: ensured + .previous_root_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + warning: ensured.warning, + }; + Ok(serde_json::to_value(result)?) + } + + "workspace_ensure_default_ready" => { + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let Some(workspace) = manager.get_default()? else { + return Ok(serde_json::json!(null)); + }; + let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; + let result = WorkspaceEnsureResult { + workspace_id: workspace.id, + root_path: ensured.root_path.to_string_lossy().to_string(), + existed: ensured.existed, + created: ensured.created, + repaired: ensured.repaired, + relocated: ensured.relocated, + previous_root_path: ensured + .previous_root_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + warning: ensured.warning, + }; + Ok(serde_json::to_value(Some(result))?) + } + + "workspace_get_by_path" => { + let args = args.unwrap_or_default(); + let root_path = get_string_arg(&args, "rootPath", "root_path")?; + let manager = WorkspaceManager::new(get_db(state)?.clone()); + let workspace = manager.get_by_path(&PathBuf::from(root_path))?; + Ok(serde_json::to_value(workspace.map(WorkspaceListItem::from))?) + } + + "workspace_get_projects_root" => { + let root_dir = get_workspace_projects_root_dir()?; + Ok(serde_json::json!(root_dir.to_string_lossy().to_string())) + } + + "workspace_resolve_project_path" => { + let args = args.unwrap_or_default(); + let name = get_string_arg(&args, "name", "name")?; + let root_dir = get_workspace_projects_root_dir()?; + let dir_name = sanitize_project_dir_name(&name); + let project_path = root_dir.join(dir_name); + Ok(serde_json::json!(project_path.to_string_lossy().to_string())) + } + + "get_or_create_default_project" => { + let manager = WorkspaceManager::new(get_db(state)?.clone()); + + if let Some(workspace) = manager.get_default()? { + return Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?); + } + + let default_project_path = get_workspace_projects_root_dir()?.join("default"); + std::fs::create_dir_all(&default_project_path) + .map_err(|e| format!("创建默认项目目录失败: {e}"))?; + + let workspace = manager.create_with_type( + "默认项目".to_string(), + default_project_path, + WorkspaceType::Persistent, + )?; + manager.set_default(&workspace.id)?; + let workspace = manager.get(&workspace.id)?.ok_or("创建默认项目失败")?; + Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) + } + + "content_create" => { + let args = args.unwrap_or_default(); + let request: BridgeCreateContentRequest = parse_nested_arg(&args, "request")?; + let manager = ContentManager::new(get_db(state)?.clone()); + let create_request = ContentCreateRequest { + project_id: request.project_id, + title: request.title, + content_type: request + .content_type + .map(|value| crate::content::ContentType::from_str(&value)), + order: request.order, + body: request.body, + metadata: request.metadata, + }; + let content = manager.create(create_request)?; + Ok(serde_json::to_value(ContentDetail::from(content))?) + } + + "content_get" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let manager = ContentManager::new(get_db(state)?.clone()); + let content = manager.get(&id)?; + Ok(serde_json::to_value(content.map(ContentDetail::from))?) + } + + "content_get_theme_workbench_document_state" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let manager = ContentManager::new(get_db(state)?.clone()); + let content = manager.get(&id)?; + let document_state: Option = content.and_then(|item| { + parse_theme_workbench_document_state(&item.id, item.metadata.as_ref()) + }); + Ok(serde_json::to_value(document_state)?) + } + + "content_list" => { + let args = args.unwrap_or_default(); + let project_id = get_string_arg(&args, "projectId", "project_id")?; + let query: Option = parse_optional_nested_arg(&args, "query")?; + let manager = ContentManager::new(get_db(state)?.clone()); + let list_query = query.map(|query| ContentListQuery { + status: query.status.map(|value| ContentStatus::from_str(&value)), + content_type: query + .content_type + .map(|value| crate::content::ContentType::from_str(&value)), + search: query.search, + sort_by: query.sort_by, + sort_order: query.sort_order, + offset: query.offset, + limit: query.limit, + }); + let contents = manager.list_by_project(&project_id, list_query)?; + let items: Vec<_> = contents.into_iter().map(ContentListItem::from).collect(); + Ok(serde_json::to_value(items)?) + } + + "content_update" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let request: BridgeUpdateContentRequest = parse_nested_arg(&args, "request")?; + let manager = ContentManager::new(get_db(state)?.clone()); + let update_request = ContentUpdateRequest { + title: request.title, + status: request.status.map(|value| ContentStatus::from_str(&value)), + order: request.order, + body: request.body, + metadata: request.metadata, + session_id: request.session_id, + }; + let content = manager.update(&id, update_request)?; + Ok(serde_json::to_value(ContentDetail::from(content))?) + } + + "content_delete" => { + let args = args.unwrap_or_default(); + let id = get_string_arg(&args, "id", "id")?; + let manager = ContentManager::new(get_db(state)?.clone()); + Ok(serde_json::to_value(manager.delete(&id)?)?) + } + + "content_reorder" => { + let args = args.unwrap_or_default(); + let project_id = get_string_arg(&args, "projectId", "project_id")?; + let content_ids = args + .get("contentIds") + .or_else(|| args.get("content_ids")) + .cloned() + .ok_or("缺少参数: contentIds/content_ids")?; + let content_ids: Vec = serde_json::from_value(content_ids)?; + let manager = ContentManager::new(get_db(state)?.clone()); + manager.reorder(&project_id, content_ids)?; + Ok(serde_json::json!(null)) + } + + "content_stats" => { + let args = args.unwrap_or_default(); + let project_id = get_string_arg(&args, "projectId", "project_id")?; + let manager = ContentManager::new(get_db(state)?.clone()); + Ok(serde_json::to_value(manager.get_project_stats(&project_id)?)?) + } + + "list_materials" => { + let args = args.unwrap_or_default(); + let project_id = get_string_arg(&args, "project_id", "projectId")?; + let filter: Option = + parse_optional_nested_arg(&args, "filter")?; + + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let materials = proxycast_services::material_service::MaterialService::list_materials( + &conn, + &project_id, + filter, + ) + .map_err(|e| format!("获取素材列表失败: {e}"))?; + Ok(serde_json::to_value(materials)?) + } else { + Ok(serde_json::json!([])) + } + } + + "get_material_count" => { + let args = args.unwrap_or_default(); + let project_id = get_string_arg(&args, "project_id", "projectId")?; + + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let count = crate::database::dao::material_dao::MaterialDao::count(&conn, &project_id) + .map_err(|e| format!("获取素材数量失败: {e}"))?; + Ok(serde_json::json!(count)) + } else { + Ok(serde_json::json!(0)) + } + } + + "project_memory_get" => { + let args = args.unwrap_or_default(); + let project_id = get_string_arg(&args, "project_id", "projectId")?; + + if let Some(db) = &state.db { + let manager = crate::memory::MemoryManager::new(db.clone()); + let memory = manager + .get_project_memory(&project_id) + .map_err(|e| format!("获取项目记忆失败: {e}"))?; + Ok(serde_json::to_value(memory)?) + } else { + Err("Database not initialized".into()) + } + } + // ========== 模型相关 ========== "get_models" => { // 返回可用模型列表 @@ -257,6 +932,44 @@ pub async fn handle_command( })) } + "get_model_registry" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + + Ok(serde_json::to_value(service.get_all_models().await)?) + } + + "get_model_preferences" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + + let preferences = service.get_all_preferences().await?; + Ok(serde_json::to_value(preferences)?) + } + + "get_model_sync_state" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + + Ok(serde_json::to_value(service.get_sync_state().await)?) + } + + "refresh_model_registry" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + + let count = service.force_reload().await?; + Ok(serde_json::json!(count)) + } + "get_model_registry_provider_ids" => { match load_model_registry_provider_ids_from_resources() { Ok(provider_ids) => Ok(serde_json::to_value(provider_ids)?), @@ -274,6 +987,231 @@ pub async fn handle_command( } } + "unified_memory_stats" => { + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + + let (total_entries, memory_count, storage_used): (i64, i64, i64) = conn + .query_row( + "SELECT COUNT(*), COUNT(DISTINCT session_id), COALESCE(SUM(length(title) + length(content) + length(summary) + length(tags)), 0) FROM unified_memory WHERE archived = 0", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| format!("统计记忆失败: {e}"))?; + + let mut category_counts: std::collections::HashMap = + std::collections::HashMap::new(); + let mut stmt = conn + .prepare( + "SELECT category, COUNT(*) FROM unified_memory WHERE archived = 0 GROUP BY category", + ) + .map_err(|e| format!("构建分类统计查询失败: {e}"))?; + + let rows = stmt + .query_map([], |row| { + let category_raw: String = row.get(0)?; + let count: i64 = row.get(1)?; + Ok((category_raw, count)) + }) + .map_err(|e| format!("分类统计查询失败: {e}"))?; + + for row in rows.flatten() { + if let Some(category) = normalize_unified_category_value(&row.0) { + category_counts.insert(category.to_string(), row.1.max(0) as u32); + } + } + + let categories = ordered_unified_categories() + .iter() + .map(|category| crate::commands::unified_memory_cmd::MemoryCategoryStat { + category: (*category).to_string(), + count: *category_counts.get(*category).unwrap_or(&0), + }) + .collect(); + + let response = crate::commands::unified_memory_cmd::MemoryStatsResponse { + total_entries: total_entries.max(0) as u32, + storage_used: storage_used.max(0) as u64, + memory_count: memory_count.max(0) as u32, + categories, + }; + + Ok(serde_json::to_value(response)?) + } else { + Ok(serde_json::json!({ + "total_entries": 0, + "storage_used": 0, + "memory_count": 0, + "categories": [], + })) + } + } + + "unified_memory_list" => { + let args = args.unwrap_or_default(); + let filters: Option = + parse_optional_nested_arg(&args, "filters")?; + let filters = filters.unwrap_or_default(); + + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + + let archived = filters.archived.unwrap_or(false); + let sort_by = normalize_unified_sort_by(filters.sort_by.as_deref()); + let order = normalize_unified_sort_order(filters.order.as_deref()); + let limit = filters.limit.unwrap_or(120).clamp(1, 1000) as i64; + let offset = filters.offset.unwrap_or(0) as i64; + + let mut where_parts = vec!["archived = ?".to_string()]; + let mut values: Vec = vec![Value::from(if archived { 1 } else { 0 })]; + + if let Some(session_id) = filters.session_id.filter(|value| !value.trim().is_empty()) { + where_parts.push("session_id = ?".to_string()); + values.push(Value::from(session_id)); + } + + if let Some(memory_type) = filters.memory_type { + let encoded = serde_json::to_string(&memory_type) + .map_err(|e| format!("序列化 memory_type 失败: {e}"))?; + where_parts.push("memory_type = ?".to_string()); + values.push(Value::from(encoded)); + } + + if let Some(category) = filters.category { + let encoded = serde_json::to_string(&category) + .map_err(|e| format!("序列化 category 失败: {e}"))?; + where_parts.push("category = ?".to_string()); + values.push(Value::from(encoded)); + } + + let sql = format!( + "SELECT id, session_id, memory_type, category, title, content, summary, tags, confidence, importance, access_count, last_accessed_at, source, created_at, updated_at, archived FROM unified_memory WHERE {} ORDER BY {} {} LIMIT ? OFFSET ?", + where_parts.join(" AND "), + sort_by, + order, + ); + + values.push(Value::from(limit)); + values.push(Value::from(offset)); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("构建查询失败: {e}"))?; + + let memories = stmt + .query_map(params_from_iter(values), parse_unified_memory_row) + .map_err(|e| format!("查询记忆失败: {e}"))? + .collect::, rusqlite::Error>>() + .map_err(|e| format!("解析记忆失败: {e}"))?; + + Ok(serde_json::to_value(memories)?) + } else { + Ok(serde_json::json!([])) + } + } + + "execution_run_list" => { + let args = args.unwrap_or_default(); + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as usize); + let offset = args + .get("offset") + .and_then(|value| value.as_u64()) + .map(|value| value as usize); + + if let Some(db) = &state.db { + let tracker = crate::services::execution_tracker_service::ExecutionTracker::new( + db.clone(), + ); + let runs = tracker.list_runs(limit.unwrap_or(50).clamp(1, 200), offset.unwrap_or(0))?; + Ok(serde_json::to_value(runs)?) + } else { + Ok(serde_json::json!([])) + } + } + + "aster_session_get" => { + let args = args.unwrap_or_default(); + let session_id = get_string_arg(&args, "session_id", "sessionId")?; + + if let Some(db) = &state.db { + let session = crate::agent::AsterAgentWrapper::get_session_sync(db, &session_id) + .map_err(|e| format!("获取 Aster 会话失败: {e}"))?; + Ok(serde_json::to_value(session)?) + } else { + Err("Database not initialized".into()) + } + } + + "aster_session_list" => { + if let Some(db) = &state.db { + let sessions = crate::agent::AsterAgentWrapper::list_sessions_sync(db) + .map_err(|e| format!("获取 Aster 会话列表失败: {e}"))?; + Ok(serde_json::to_value(sessions)?) + } else { + Ok(serde_json::json!([])) + } + } + + "report_frontend_crash" => { + let args = args.unwrap_or_default(); + let report: crate::app::commands::FrontendCrashReport = + parse_nested_arg(&args, "report")?; + + let sanitized_message = crate::logger::sanitize_log_message(&report.message); + let sanitized_component = report + .component + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "unknown".to_string()); + let sanitized_step = report + .workflow_step + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "unknown".to_string()); + let sanitized_mode = report + .creation_mode + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "unknown".to_string()); + let stack_preview = report + .stack + .as_deref() + .map(crate::logger::sanitize_log_message) + .map(|stack| stack.lines().take(3).collect::>().join(" | ")) + .unwrap_or_default(); + + state.logs.write().await.add( + "error", + &format!( + "[FrontendCrash] component={sanitized_component} step={sanitized_step} mode={sanitized_mode} message={sanitized_message} stack={stack_preview}" + ), + ); + + Ok(serde_json::json!({ "success": true })) + } + + "get_conversation_memory_overview" => { + let args = args.unwrap_or_default(); + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as u32); + let overview = crate::commands::memory_management_cmd::get_conversation_memory_overview(limit) + .await + .map_err(|e| format!("获取对话记忆总览失败: {e}"))?; + Ok(serde_json::to_value(overview)?) + } + + "get_conversation_memory_stats" => { + let stats = crate::commands::memory_management_cmd::get_conversation_memory_stats() + .await + .map_err(|e| format!("获取对话记忆统计失败: {e}"))?; + Ok(serde_json::to_value(stats)?) + } + // ========== 网络信息 ========== "get_network_info" => { // 返回网络信息 @@ -439,3 +1377,135 @@ pub async fn handle_command( .into()), } } + +#[cfg(test)] +mod tests { + use super::*; + use proxycast_core::{config::Config, database::schema::create_tables}; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + use tempfile::TempDir; + use tokio::sync::RwLock; + + fn make_test_db() -> crate::database::DbConnection { + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + Arc::new(Mutex::new(conn)) + } + + fn make_test_state() -> DevBridgeState { + let config = Config::default(); + + DevBridgeState { + server: Arc::new(RwLock::new(proxycast_server::ServerState::new( + config.clone(), + ))), + logs: Arc::new(RwLock::new(crate::logger::create_log_store_from_config( + &config.logging, + ))), + db: Some(make_test_db()), + pool_service: Arc::new( + proxycast_services::provider_pool_service::ProviderPoolService::new(), + ), + api_key_provider_service: Arc::new( + proxycast_services::api_key_provider_service::ApiKeyProviderService::new(), + ), + connect_state: Arc::new(RwLock::new(None)), + model_registry: Arc::new(RwLock::new(None)), + skill_service: Arc::new( + proxycast_services::skill_service::SkillService::new().unwrap(), + ), + shared_stats: Arc::new(parking_lot::RwLock::new( + proxycast_infra::telemetry::StatsAggregator::default(), + )), + } + } + + #[tokio::test] + async fn workspace_commands_roundtrip() { + let state = make_test_state(); + let temp_dir = TempDir::new().unwrap(); + let root_path = temp_dir.path().join("social-workbench"); + + let created_value = handle_command( + &state, + "workspace_create", + Some(serde_json::json!({ + "request": { + "name": "社媒项目", + "rootPath": root_path.to_string_lossy().to_string(), + "workspaceType": "social-media" + } + })), + ) + .await + .unwrap(); + let created_id = created_value["id"].as_str().unwrap().to_string(); + + assert_eq!(created_value["name"], "社媒项目"); + assert_eq!(created_value["workspace_type"], "social-media"); + + let list_value = handle_command(&state, "workspace_list", None) + .await + .unwrap(); + let list = list_value.as_array().unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0]["id"], created_id); + } + + #[tokio::test] + async fn content_commands_roundtrip() { + let state = make_test_state(); + let temp_dir = TempDir::new().unwrap(); + let root_path = temp_dir.path().join("content-project"); + + let workspace_value = handle_command( + &state, + "workspace_create", + Some(serde_json::json!({ + "request": { + "name": "内容项目", + "rootPath": root_path.to_string_lossy().to_string(), + "workspaceType": "social-media" + } + })), + ) + .await + .unwrap(); + let workspace_id = workspace_value["id"].as_str().unwrap().to_string(); + + let created_value = handle_command( + &state, + "content_create", + Some(serde_json::json!({ + "request": { + "project_id": workspace_id.clone(), + "title": "首条社媒文稿", + "content_type": "post", + "body": "正文内容" + } + })), + ) + .await + .unwrap(); + let created: ContentDetail = serde_json::from_value(created_value).unwrap(); + + assert_eq!(created.title, "首条社媒文稿"); + assert_eq!(created.content_type, "post"); + + let list_value = handle_command( + &state, + "content_list", + Some(serde_json::json!({ + "projectId": workspace_id, + })), + ) + .await + .unwrap(); + let list: Vec = serde_json::from_value(list_value).unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, created.id); + } +} diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 249b4bf54..869a57da6 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -68,6 +68,24 @@ fn skills_root_from_home(home_dir: &Path) -> PathBuf { home_dir.join(".proxycast").join("skills") } +/// 从 SKILL.md 内容中提取版本号,返回 (major, minor, patch) +fn parse_skill_version(content: &str) -> Option<(u32, u32, u32)> { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("version:") { + let version_str = trimmed.splitn(2, ':').nth(1)?.trim(); + let parts: Vec<&str> = version_str.split('.').collect(); + if parts.len() == 3 { + let major = parts[0].trim().parse::().ok()?; + let minor = parts[1].trim().parse::().ok()?; + let patch = parts[2].trim().parse::().ok()?; + return Some((major, minor, patch)); + } + } + } + None +} + 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) @@ -78,6 +96,20 @@ fn ensure_default_local_skills_in_home(home_dir: &Path) -> Result, S let skill_dir = skills_root.join(skill_name); let skill_md_path = skill_dir.join("SKILL.md"); if skill_md_path.exists() { + // 比较版本号,若内置版本更新则自动升级 + let existing_content = fs::read_to_string(&skill_md_path).unwrap_or_default(); + let existing_version = parse_skill_version(&existing_content); + let embedded_version = parse_skill_version(skill_content); + match (existing_version, embedded_version) { + (Some(ev), Some(bv)) if bv > ev => { + // 内置版本更新,覆盖升级 + fs::write(&skill_md_path, skill_content).map_err(|e| { + format!("升级默认技能失败 {}: {e}", skill_md_path.display()) + })?; + installed.push(skill_name.to_string()); + } + _ => continue, // 版本相同或无法比较,跳过 + } continue; } @@ -124,19 +156,61 @@ mod tests { .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 不应被重新安装" + "无版本信息的已存在 skill 不应被重新安装" ); let current_content = fs::read_to_string(&skill_md_path).expect("read skill"); assert_eq!(current_content, existing_content); } + #[test] + fn should_upgrade_skill_when_newer_version_available() { + 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 old_content = "---\nname: social_post_with_cover\nversion: 1.0.0\n---\nold content"; + fs::write(&skill_md_path, old_content).expect("write old skill"); + + let installed = ensure_default_local_skills_in_home(temp.path()).expect("install"); + assert!( + installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()), + "内置版本更新时应自动升级" + ); + + let current_content = fs::read_to_string(&skill_md_path).expect("read skill"); + assert_ne!(current_content, old_content, "旧版本内容应被替换"); + assert!( + current_content.contains("steps-json"), + "升级后应包含 steps-json 字段" + ); + } + + #[test] + fn should_parse_skill_version() { + assert_eq!( + parse_skill_version("---\nversion: 1.3.0\n---\n"), + Some((1, 3, 0)) + ); + assert_eq!( + parse_skill_version("---\nname: test\nversion: 2.10.5\n---\n"), + Some((2, 10, 5)) + ); + assert_eq!(parse_skill_version("no version here"), None); + } + #[test] fn should_embed_social_image_tool_contract_in_default_skill() { assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index c21892558..c7ec56433 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -4,7 +4,7 @@ "version": "0.70.0", "identifier": "com.proxycast.app", "build": { - "beforeDevCommand": "npm run dev", + "beforeDevCommand": "npm run dev:web-bridge", "devUrl": "http://127.0.0.1:1420", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" @@ -58,7 +58,9 @@ }, "deep-link": { "desktop": { - "schemes": ["proxycast"] + "schemes": [ + "proxycast" + ] } } } diff --git a/src/App.tsx b/src/App.tsx index 727983e61..c4d2f313d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import React, { useState, useEffect, useCallback } from "react"; import styled from "styled-components"; -import { invoke } from "@tauri-apps/api/core"; +import { safeInvoke } from "@/lib/dev-bridge"; import { withI18nPatch } from "./i18n/withI18nPatch"; import { SplashScreen } from "./components/SplashScreen"; import { AppSidebar } from "./components/AppSidebar"; @@ -327,7 +327,7 @@ function AppContent() { }, [registryError]); useEffect(() => { - void invoke<{ + void safeInvoke<{ workspaceId: string; rootPath: string; created: boolean; diff --git a/src/components/agent/chat/components/Inputbar/components/A2UISubmissionNotice.tsx b/src/components/agent/chat/components/Inputbar/components/A2UISubmissionNotice.tsx new file mode 100644 index 000000000..fcc4d3b11 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/A2UISubmissionNotice.tsx @@ -0,0 +1,138 @@ +import { useEffect, useMemo, useState } from "react"; +import { CheckCircle2, ChevronDown } from "lucide-react"; +import styled from "styled-components"; + +export interface A2UISubmissionNoticeData { + title: string; + summary: string; +} + +interface A2UISubmissionNoticeProps { + notice: A2UISubmissionNoticeData; + visible: boolean; +} + +const Container = styled.div<{ $visible: boolean }>` + margin-bottom: 8px; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 10px; + border-radius: 10px; + border: 1px solid hsl(var(--primary) / 0.18); + background: linear-gradient( + 180deg, + hsl(var(--background) / 0.96) 0%, + hsl(var(--primary) / 0.06) 100% + ); + box-shadow: 0 6px 18px hsl(var(--foreground) / 0.05); + opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + transform: translateY(${({ $visible }) => ($visible ? "0" : "-4px")}); + transition: + opacity 0.18s ease, + transform 0.18s ease; + pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")}; + will-change: opacity, transform; +`; + +const IconWrap = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + color: hsl(var(--primary)); + flex-shrink: 0; + margin-top: 1px; +`; + +const Content = styled.div` + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const Title = styled.div` + font-size: 12px; + line-height: 1.3; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const Summary = styled.div<{ $expanded?: boolean }>` + font-size: 11px; + line-height: 1.4; + color: hsl(var(--muted-foreground)); + + ${({ $expanded }) => + $expanded + ? ` + display: block; + white-space: normal; + ` + : ` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + `} +`; + +const ToggleButton = styled.button<{ $expanded?: boolean }>` + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + padding: 0; + border: none; + background: transparent; + color: hsl(var(--primary)); + font-size: 11px; + line-height: 1.2; + cursor: pointer; + + &:hover { + color: hsl(var(--primary) / 0.82); + } + + svg { + width: 12px; + height: 12px; + transition: transform 0.18s ease; + transform: rotate(${({ $expanded }) => ($expanded ? "180deg" : "0deg")}); + } +`; + +export function A2UISubmissionNotice({ + notice, + visible, +}: A2UISubmissionNoticeProps) { + const [expanded, setExpanded] = useState(false); + + const canExpand = useMemo(() => notice.summary.length > 42, [notice.summary]); + + useEffect(() => { + setExpanded(false); + }, [notice.summary]); + + return ( + + + + + + {notice.title} + {notice.summary} + {canExpand ? ( + setExpanded((prev) => !prev)} + > + {expanded ? "收起" : "展开"} + + + ) : null} + + + ); +} diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 39c3d7f39..4eb72d82a 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -7,6 +7,12 @@ 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 { A2UIRenderer } from "@/components/content-creator/a2ui"; +import { + A2UISubmissionNotice, + type A2UISubmissionNoticeData, +} from "./components/A2UISubmissionNotice"; +import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types"; import { FolderOpen, ChevronUp, @@ -40,6 +46,99 @@ const TaskFilesArea = styled.div` margin: 0; `; +// A2UI Form 卡片容器(在输入框上方) +const A2UIFormCard = styled.div` + position: relative; + margin-bottom: 10px; + padding: 12px; + background: hsl(var(--background) / 0.97); + border: 1px solid hsl(var(--border) / 0.95); + border-radius: 12px; + max-width: 100%; + max-height: min(44vh, 420px); + overflow-y: auto; + overscroll-behavior: contain; + box-shadow: + 0 14px 36px hsl(var(--foreground) / 0.10), + 0 0 0 1px hsl(var(--background) / 0.72); + backdrop-filter: blur(14px); + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) transparent; + + &::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + bottom: -12px; + height: 16px; + margin: 0 -12px -12px; + pointer-events: none; + background: linear-gradient( + 180deg, + hsl(var(--background) / 0) 0%, + hsl(var(--background) / 0.9) 100% + ); + } + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 999px; + } + + .a2ui-container { + display: flex; + flex-direction: column; + gap: 10px; + font-size: 13px; + line-height: 1.4; + } + + .a2ui-container > * + * { + margin-top: 0; + } + + .a2ui-container .text-sm, + .a2ui-container label, + .a2ui-container [class*="text-sm"] { + font-size: 13px; + line-height: 1.35; + } + + .a2ui-container .text-xs, + .a2ui-container p, + .a2ui-container [class*="text-xs"] { + font-size: 12px; + line-height: 1.3; + } + + .a2ui-container input, + .a2ui-container textarea { + padding: 7px 9px; + font-size: 12px; + line-height: 1.35; + border-color: hsl(var(--border) / 0.95); + background: hsl(var(--background)); + } + + .a2ui-container textarea { + min-height: 72px; + } + + .a2ui-container button { + padding: 6px 10px; + font-size: 12px; + line-height: 1.3; + box-shadow: 0 1px 0 hsl(var(--background) / 0.35); + } +`; + + // 按钮和面板的包装容器 const TaskFilesWrapper = styled.div` position: relative; @@ -552,6 +651,12 @@ interface InputbarProps { themeWorkbenchGate?: ThemeWorkbenchGateState | null; workflowSteps?: ThemeWorkbenchWorkflowStep[]; themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision"; + /** 待处理的 A2UI Form(显示在输入框上方) */ + pendingA2UIForm?: A2UIResponse | null; + /** A2UI Form 提交回调 */ + onA2UISubmit?: (formData: A2UIFormData) => void; + /** A2UI 表单已提交提示 */ + a2uiSubmissionNotice?: A2UISubmissionNoticeData | null; } export const Inputbar: React.FC = ({ @@ -587,6 +692,9 @@ export const Inputbar: React.FC = ({ themeWorkbenchGate, workflowSteps = [], themeWorkbenchRunState, + pendingA2UIForm, + onA2UISubmit, + a2uiSubmissionNotice, }) => { const [localActiveTools, setLocalActiveTools] = useState< Record @@ -598,9 +706,14 @@ export const Inputbar: React.FC = ({ const [isFullscreen, setIsFullscreen] = useState(false); const [themeWorkbenchQueueCollapsed, setThemeWorkbenchQueueCollapsed] = useState(false); + const [visibleA2UISubmissionNotice, setVisibleA2UISubmissionNotice] = + useState(null); + const [isA2UISubmissionNoticeVisible, setIsA2UISubmissionNoticeVisible] = + useState(false); const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill(); const fileInputRef = useRef(null); const textareaRef = useRef(null); + const a2uiSubmissionNoticeTimerRef = useRef | null>(null); // Hint 路由 const [showHintPopup, setShowHintPopup] = useState(false); @@ -1056,6 +1169,53 @@ export const Inputbar: React.FC = ({ : inputAdapter.state.isSending : false; + const shouldShowA2UISubmissionNotice = Boolean( + !pendingA2UIForm && + a2uiSubmissionNotice && + (!isThemeWorkbenchVariant || + (!renderThemeWorkbenchGeneratingPanel && + themeWorkbenchQueueItems.length === 0 && + (themeWorkbenchGate?.status ?? "idle") === "idle")), + ); + + useEffect(() => { + return () => { + if (a2uiSubmissionNoticeTimerRef.current) { + clearTimeout(a2uiSubmissionNoticeTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (a2uiSubmissionNoticeTimerRef.current) { + clearTimeout(a2uiSubmissionNoticeTimerRef.current); + a2uiSubmissionNoticeTimerRef.current = null; + } + + if (shouldShowA2UISubmissionNotice && a2uiSubmissionNotice) { + setVisibleA2UISubmissionNotice(a2uiSubmissionNotice); + const frameId = window.requestAnimationFrame(() => { + setIsA2UISubmissionNoticeVisible(true); + }); + return () => { + window.cancelAnimationFrame(frameId); + }; + } + + setIsA2UISubmissionNoticeVisible(false); + a2uiSubmissionNoticeTimerRef.current = setTimeout(() => { + setVisibleA2UISubmissionNotice(null); + a2uiSubmissionNoticeTimerRef.current = null; + }, 180); + + return () => { + if (a2uiSubmissionNoticeTimerRef.current) { + clearTimeout(a2uiSubmissionNoticeTimerRef.current); + a2uiSubmissionNoticeTimerRef.current = null; + } + }; + }, [a2uiSubmissionNotice, shouldShowA2UISubmissionNotice]); + return (
= ({ )} + {/* A2UI 已提交提示 / 待填写表单 */} + {visibleA2UISubmissionNotice ? ( + + ) : null} + {pendingA2UIForm && onA2UISubmit ? ( + + + + ) : null} ` @@ -163,6 +164,132 @@ const SidebarBody = styled.div` overflow-x: visible; `; +/* ── 执行日志 Timeline 样式 ── */ + +const ExecLogContainer = styled.div` + padding: 12px 0 12px 14px; +`; + +const ExecLogTimeline = styled.div` + position: relative; + padding-left: 20px; + &::before { + content: ""; + position: absolute; + left: 6px; + top: 8px; + bottom: 8px; + width: 1px; + background: hsl(var(--border)); + } +`; + +const ExecLogItem = styled.div` + position: relative; + margin-bottom: 14px; + &:last-child { margin-bottom: 0; } +`; + +const ExecLogDot = styled.span<{ $type: string; $status?: string }>` + position: absolute; + left: -24px; + top: 4px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid ${(p) => { + if (p.$status === "failed") return "hsl(var(--destructive))"; + if (p.$status === "running") return "hsl(38 92% 50%)"; + if (p.$type === "user") return "hsl(217 91% 60%)"; + if (p.$type === "thinking") return "hsl(270 70% 55%)"; + if (p.$type === "response") return "hsl(var(--muted-foreground))"; + if (p.$type === "run") return "hsl(142 71% 45%)"; + if (p.$type === "task") return "hsl(25 95% 53%)"; + return "hsl(var(--primary) / 0.6)"; + }}; + background: ${(p) => { + if (p.$status === "running") return "hsl(38 92% 50% / 0.3)"; + if (p.$status === "failed") return "hsl(var(--destructive) / 0.2)"; + if (p.$status === "completed") return "hsl(142 71% 45% / 0.2)"; + if (p.$type === "user") return "hsl(217 91% 60% / 0.2)"; + if (p.$type === "run") return "hsl(142 71% 45% / 0.2)"; + if (p.$type === "task") return "hsl(25 95% 53% / 0.2)"; + return "transparent"; + }}; +`; + +const ExecLogHeader = styled.div` + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 3px; +`; + +const ExecLogBadge = styled.span<{ $type: string; $status?: string }>` + display: inline-flex; + align-items: center; + height: 17px; + padding: 0 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + color: ${(p) => { + if (p.$status === "failed") return "hsl(var(--destructive))"; + if (p.$status === "running") return "hsl(38 92% 40%)"; + if (p.$status === "completed") return "hsl(142 71% 35%)"; + if (p.$type === "user") return "hsl(217 91% 45%)"; + if (p.$type === "thinking") return "hsl(270 70% 50%)"; + if (p.$type === "run") return "hsl(142 71% 35%)"; + if (p.$type === "task") return "hsl(25 95% 53%)"; + return "hsl(var(--muted-foreground))"; + }}; + background: ${(p) => { + if (p.$status === "failed") return "hsl(var(--destructive) / 0.1)"; + if (p.$status === "running") return "hsl(38 92% 50% / 0.12)"; + if (p.$status === "completed") return "hsl(142 71% 45% / 0.1)"; + if (p.$type === "user") return "hsl(217 91% 60% / 0.1)"; + if (p.$type === "thinking") return "hsl(270 70% 55% / 0.1)"; + if (p.$type === "run") return "hsl(142 71% 45% / 0.1)"; + if (p.$type === "task") return "hsl(25 95% 53% / 0.1)"; + return "hsl(var(--muted) / 0.6)"; + }}; +`; + +const ExecLogTime = styled.span` + margin-left: auto; + font-size: 10px; + color: hsl(var(--muted-foreground) / 0.7); + white-space: nowrap; + flex-shrink: 0; +`; + +const ExecLogContent = styled.div` + font-size: 11.5px; + color: hsl(var(--foreground) / 0.8); + line-height: 1.55; + word-break: break-word; + white-space: pre-wrap; +`; + +const ExecLogMeta = styled.div` + margin-top: 3px; + font-size: 10.5px; + color: hsl(var(--muted-foreground)); + line-height: 1.45; + word-break: break-word; + white-space: pre-wrap; +`; + +const ExecLogEmpty = styled.div` + padding: 20px 16px; + text-align: center; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +/* ── end 执行日志 ── */ + const SectionBadge = styled.span` display: inline-flex; align-items: center; @@ -938,7 +1065,7 @@ const ActivityStepItem = styled.div` padding: 5px 6px; `; -const RunLinkButton = styled.button` +const _RunLinkButton = styled.button` border: 0; background: transparent; padding: 0; @@ -954,7 +1081,7 @@ const RunLinkButton = styled.button` } `; -const RunDetailPanel = styled.div` +const _RunDetailPanel = styled.div` margin-top: 8px; border: 1px solid hsl(var(--border)); border-radius: 8px; @@ -962,14 +1089,14 @@ const RunDetailPanel = styled.div` padding: 8px; `; -const RunDetailTitle = styled.div` +const _RunDetailTitle = styled.div` font-size: 11px; font-weight: 600; color: hsl(var(--foreground)); margin-bottom: 6px; `; -const RunDetailRow = styled.div` +const _RunDetailRow = styled.div` font-size: 11px; color: hsl(var(--muted-foreground)); line-height: 1.45; @@ -1002,7 +1129,7 @@ const RunDetailArtifactPath = styled.code` text-overflow: ellipsis; `; -const RunDetailCode = styled.pre` +const _RunDetailCode = styled.pre` margin-top: 6px; font-size: 10px; line-height: 1.4; @@ -1052,7 +1179,7 @@ function getBranchStatusText(status: TopicBranchStatus): string { return "备选"; } -function formatGateLabel( +function _formatGateLabel( gateKey?: SidebarActivityLog["gateKey"], ): string | null { if (!gateKey || gateKey === "idle") { @@ -1070,7 +1197,7 @@ function formatGateLabel( return null; } -function formatRunIdShort(runId?: string): string | null { +function _formatRunIdShort(runId?: string): string | null { const trimmed = runId?.trim(); if (!trimmed) { return null; @@ -1081,7 +1208,7 @@ function formatRunIdShort(runId?: string): string | null { return `${trimmed.slice(0, 8)}…`; } -function formatRunStatusLabel(status: AgentRun["status"]): string { +function _formatRunStatusLabel(status: AgentRun["status"]): string { if (status === "queued") return "排队中"; if (status === "running") return "运行中"; if (status === "success") return "成功"; @@ -1215,7 +1342,7 @@ function parseRunMetadataSummary(raw: string | null): ParsedRunMetadataSummary { } } -function formatStageLabelByKey(raw: string): string { +function _formatStageLabelByKey(raw: string): string { if (raw === "topic_select") { return "选题闸门"; } @@ -1241,7 +1368,7 @@ async function writeClipboardText(text: string): Promise { } type BranchMode = "topic" | "version"; -type SidebarTab = "context" | "workflow"; +type SidebarTab = "context" | "workflow" | "log"; type ActivityStatus = SidebarActivityLog["status"]; export interface ThemeWorkbenchCreationTaskEvent { @@ -1313,7 +1440,7 @@ function resolveActivityGroupKey(log: SidebarActivityLog): { }; } -function resolveActivityMarker(status: ActivityStatus): string { +function _resolveActivityMarker(status: ActivityStatus): string { if (status === "completed") { return "✓"; } @@ -1323,7 +1450,7 @@ function resolveActivityMarker(status: ActivityStatus): string { return "●"; } -function formatLogActionLabel(log: SidebarActivityLog): string { +function _formatLogActionLabel(log: SidebarActivityLog): string { const normalizedSource = log.source?.trim().toLowerCase(); if (normalizedSource === "skill") { return `技能:${log.name}`; @@ -1331,7 +1458,7 @@ function formatLogActionLabel(log: SidebarActivityLog): string { return `动作:${log.name}`; } -function formatArtifactPathsLabel(paths?: string[]): string { +function _formatArtifactPathsLabel(paths?: string[]): string { if (!paths || paths.length === 0) { return "主稿内容"; } @@ -1441,6 +1568,8 @@ interface ThemeWorkbenchSidebarProps { activeRunDetail?: AgentRun | null; activeRunDetailLoading?: boolean; onRequestCollapse?: () => void; + /** 完整的对话消息列表,用于执行日志 tab */ + messages?: Message[]; } function ThemeWorkbenchSidebarComponent({ @@ -1470,12 +1599,13 @@ function ThemeWorkbenchSidebarComponent({ contextBudget, activityLogs, creationTaskEvents = [], - onViewRunDetail, + _onViewRunDetail, activeRunDetail, - activeRunDetailLoading = false, + _activeRunDetailLoading = false, onRequestCollapse, + messages = [], }: ThemeWorkbenchSidebarProps) { - const [showActivityLogs, setShowActivityLogs] = useState(false); + const [showActivityLogs, _setShowActivityLogs] = useState(false); const [showCreationTasks, setShowCreationTasks] = useState(true); const [activeTab, setActiveTab] = useState("context"); const [selectedSearchResultId, setSelectedSearchResultId] = useState(null); @@ -1490,16 +1620,16 @@ function ThemeWorkbenchSidebarComponent({ ); const progressPercent = workflowSteps.length > 0 ? (completedSteps / workflowSteps.length) * 100 : 0; - const runMetadataText = useMemo( + const _runMetadataText = useMemo( () => formatRunMetadata(activeRunDetail?.metadata ?? null), [activeRunDetail?.metadata], ); - const runMetadataSummary = useMemo( + const _runMetadataSummary = useMemo( () => parseRunMetadataSummary(activeRunDetail?.metadata ?? null), [activeRunDetail?.metadata], ); const runDetailSessionId = activeRunDetail?.session_id?.trim() || null; - const handleRevealArtifactInFinder = useCallback( + const _handleRevealArtifactInFinder = useCallback( async (artifactPath: string, sessionId?: string | null) => { const resolvedSessionId = sessionId?.trim() || runDetailSessionId; if (!resolvedSessionId) { @@ -1515,7 +1645,7 @@ function ThemeWorkbenchSidebarComponent({ }, [runDetailSessionId], ); - const handleOpenArtifactWithDefaultApp = useCallback( + const _handleOpenArtifactWithDefaultApp = useCallback( async (artifactPath: string, sessionId?: string | null) => { const resolvedSessionId = sessionId?.trim() || runDetailSessionId; if (!resolvedSessionId) { @@ -1851,7 +1981,164 @@ function ThemeWorkbenchSidebarComponent({ }); }, [creationTaskEvents]); - const resolveActivityGroupSessionId = useCallback( + // ── 执行日志 entries(从 messages 解析)── + interface ExecLogEntry { + id: string; + type: "user" | "thinking" | "response" | "tool" | "run" | "task"; + typeLabel: string; + content: string; + meta?: string; + timestamp: Date; + status?: "running" | "completed" | "failed"; + } + + function resolveToolLabel(toolName: string): string { + const n = toolName.trim().toLowerCase(); + if (n === "list_skills") return "获取技能列表"; + if (n === "load_skill") return "加载技能"; + if (n.includes("write_file") || n.includes("create_file")) return "创建文件"; + if (n.includes("read_file")) return "读取文件"; + if (n.includes("search_query") || n.includes("web_search") || n === "search") return "网络检索"; + if (n.includes("social_generate_cover") || n.includes("generate_image")) return "生成封面图"; + if (n.includes("execute") || n.includes("bash")) return "执行命令"; + if (n.includes("context") || n.includes("retrieve")) return "检索上下文"; + return toolName; + } + + function truncate(text: string, max = 300): string { + if (!text) return ""; + const t = text.trim(); + return t.length > max ? `${t.slice(0, max)}…` : t; + } + + const execLogEntries = useMemo(() => { + const entries: ExecLogEntry[] = []; + let idx = 0; + for (const msg of messages) { + if (msg.role === "user") { + entries.push({ + id: `${msg.id}-user`, + type: "user", + typeLabel: "用户请求", + content: truncate(msg.content, 200), + timestamp: msg.timestamp, + }); + } else { + // thinking + if (msg.thinkingContent) { + entries.push({ + id: `${msg.id}-thinking`, + type: "thinking", + typeLabel: "深度思考", + content: truncate(msg.thinkingContent, 200), + timestamp: msg.timestamp, + }); + } + // tool calls + for (const tc of msg.toolCalls || []) { + let argsPreview = ""; + try { + const parsed = JSON.parse(tc.arguments || "{}"); + const keys = Object.keys(parsed); + const preview = keys.slice(0, 2).map((k) => { + const v = String(parsed[k] ?? ""); + return `${k}: ${v.slice(0, 60)}${v.length > 60 ? "…" : ""}`; + }); + argsPreview = preview.join(" · "); + } catch { + argsPreview = truncate(tc.arguments || "", 120); + } + const resultMeta = tc.result?.error + ? `❌ ${truncate(tc.result.error, 120)}` + : tc.result?.output + ? truncate(tc.result.output, 200) + : undefined; + entries.push({ + id: `${msg.id}-tc-${tc.id}-${idx++}`, + type: "tool", + typeLabel: resolveToolLabel(tc.name), + content: argsPreview || tc.name, + meta: resultMeta, + timestamp: tc.startTime || msg.timestamp, + status: tc.status, + }); + } + // text response + if (msg.content?.trim() && !msg.isThinking) { + entries.push({ + id: `${msg.id}-resp`, + type: "response", + typeLabel: "AI 响应", + content: truncate(msg.content, 200), + timestamp: msg.timestamp, + }); + } + } + } + + // ── 来自后端编排系统的运行记录(活动日志迁移)── + for (const group of groupedActivityLogs) { + const ts = (() => { + // timeLabel 格式 "HH:mm" 或 "HH:mm:ss",用今天的日期构造 + try { + const parts = group.timeLabel.split(":"); + if (parts.length >= 2) { + const d = new Date(); + d.setHours(Number(parts[0]), Number(parts[1]), Number(parts[2] || 0), 0); + return d; + } + } catch { /* ignore */ } + return new Date(0); + })(); + const skillLog = group.logs.find((l) => l.source === "skill"); + const skillName = skillLog?.name || group.source || ""; + const artifactSummary = group.artifactPaths.length > 0 + ? `产物:${group.artifactPaths.map((p) => p.split("/").pop()).join("、")}` + : undefined; + const durationLabel = group.logs.find((l) => l.durationLabel)?.durationLabel; + entries.push({ + id: `run-${group.key}`, + type: "run", + typeLabel: skillName ? `技能:${skillName}` : "编排运行", + content: skillName + ? `执行技能 ${skillName}${durationLabel ? ` ${durationLabel}` : ""}` + : `编排运行${durationLabel ? ` ${durationLabel}` : ""}`, + meta: artifactSummary, + timestamp: ts, + status: group.status, + }); + } + + // ── 来自任务提交的记录(创作任务迁移)── + for (const group of groupedCreationTaskEvents) { + const latestTask = group.tasks[group.tasks.length - 1]; + const ts = latestTask?.createdAt ? new Date(latestTask.createdAt) : new Date(0); + entries.push({ + id: `task-${group.key}`, + type: "task", + typeLabel: "任务提交", + content: group.label || group.taskType, + timestamp: ts, + status: "completed", + }); + } + + // 按时间升序排列 + entries.sort((a, b) => { + const ta = a.timestamp instanceof Date ? a.timestamp.getTime() : 0; + const tb = b.timestamp instanceof Date ? b.timestamp.getTime() : 0; + return ta - tb; + }); + + return entries; + }, [messages, groupedActivityLogs, groupedCreationTaskEvents]); + + const execLogBottomRef = useRef(null); + useEffect(() => { + execLogBottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [execLogEntries.length]); + + const _resolveActivityGroupSessionId = useCallback( (group: ActivityLogGroup): string | null => { const normalizedGroupSessionId = group.sessionId?.trim(); if (normalizedGroupSessionId) { @@ -1959,6 +2246,17 @@ function ThemeWorkbenchSidebarComponent({ {branchItems.length} + setActiveTab("log")} + > + 执行日志 + + {execLogEntries.length} + + {onRequestCollapse ? ( @@ -2151,7 +2449,7 @@ function ThemeWorkbenchSidebarComponent({ )} - ) : ( + ) : activeTab === "workflow" ? ( <>
@@ -2334,266 +2632,38 @@ function ThemeWorkbenchSidebarComponent({ ) : 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} -
- )} + ) : null} + {activeTab === "log" ? ( + + {execLogEntries.length === 0 ? ( + 暂无执行记录 + ) : ( + + {execLogEntries.map((entry) => ( + + + + + {entry.typeLabel} + + + {entry.timestamp + ? entry.timestamp instanceof Date + ? entry.timestamp.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + : new Date(entry.timestamp).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + : ""} + + + {entry.content} + {entry.meta ? {entry.meta} : null} + + ))} +
+ + )} + + ) : null} {addContextDialogOpen ? ( { }; export function useAsterAgentChat(options: UseAsterAgentChatOptions) { - const { onWriteFile, workspaceId } = options; + const { systemPrompt, onWriteFile, workspaceId } = options; const getRequiredWorkspaceId = useCallback((): string => { const resolvedWorkspaceId = workspaceId?.trim(); @@ -2161,6 +2161,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { effectiveExecutionStrategy, webSearch, autoContinue, + systemPrompt, ); } catch (error) { if (requestLogId && !requestFinished) { @@ -2191,7 +2192,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { if (unlisten) unlisten(); } }, - [ensureSession, executionStrategy, getRequiredWorkspaceId, onWriteFile], + [ensureSession, executionStrategy, getRequiredWorkspaceId, onWriteFile, systemPrompt], ); // 停止发送 diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 8fbc7153e..7a0f046c2 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -55,12 +55,17 @@ import { type CanvasStateUnion, } from "@/components/content-creator/canvas/canvasUtils"; import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import { + COVER_IMAGE_REPLACED_EVENT, + type CoverImageReplacedDetail, +} from "@/components/content-creator/canvas/document/platforms/CoverImagePlaceholder"; import type { AutoContinueRunPayload, ContentReviewRunPayload, DocumentVersion, TextStylizeRunPayload, } from "@/components/content-creator/canvas/document/types"; +import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/canvas"; import { type CanvasState as GeneralCanvasState, @@ -144,7 +149,6 @@ import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; import { getFileToStepMap } from "./utils/workflowMapping"; import { normalizeProjectId } from "./utils/topicProjectResolution"; import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch"; -import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; import { loadChatToolPreferences, saveChatToolPreferences, @@ -243,7 +247,9 @@ const FloatingInputbarContainer = styled.div` } `; -const ThemeWorkbenchInputOverlay = styled.div` +const ThemeWorkbenchInputOverlay = styled.div<{ + $hasPendingA2UIForm?: boolean; +}>` position: absolute; left: 24px; right: 24px; @@ -256,7 +262,10 @@ const ThemeWorkbenchInputOverlay = styled.div` > * { pointer-events: auto; - width: min(calc(100% - 16px), 480px); + width: ${({ $hasPendingA2UIForm }) => + $hasPendingA2UIForm + ? "min(calc(100% - 24px), 880px)" + : "min(calc(100% - 16px), 480px)"}; max-width: 100%; } `; @@ -686,10 +695,13 @@ function buildThemeWorkbenchLiveWorkflowSteps( const skillDetail = skillDetailMap[skillName] || null; const workflowSteps = skillDetail?.workflow_steps || []; if (workflowSteps.length > 0) { + const latestAssistantContent = + messages + .slice() + .reverse() + .find((m) => m.role === "assistant")?.content || ""; const activeIndex = - extractThemeWorkbenchWorkflowMarkerIndex( - assistantMessage.content || "", - ) ?? 0; + extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; return workflowSteps.map((step, index) => ({ id: step.id, title: step.name, @@ -1314,6 +1326,32 @@ function buildThemeWorkbenchWorkflowSteps( const queueItems = backendRunState?.queue_items || []; if (queueItems.length > 0) { + if (queueItems.length === 1) { + const item = queueItems[0]; + const sourceRef = item.source_ref?.trim(); + const workflowSteps = sourceRef + ? (skillDetailMap[sourceRef]?.workflow_steps || []) + : []; + if (workflowSteps.length > 0) { + const latestAssistantContent = + messages + .slice() + .reverse() + .find((m) => m.role === "assistant")?.content || ""; + const activeIndex = + extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; + return workflowSteps.map((step, index) => ({ + id: `${item.run_id}-${step.id}`, + title: step.name, + status: + index < activeIndex + ? ("completed" as StepStatus) + : index === activeIndex + ? ("active" as StepStatus) + : ("pending" as StepStatus), + })); + } + } return queueItems.map((item) => ({ id: item.run_id, title: resolveThemeWorkbenchQueueItemTitle(item, skillDetailMap), @@ -1667,9 +1705,6 @@ export function AgentChatPage({ const pendingSkillKey = useWorkbenchStore( (state) => state.pendingSkillKey, ); - const setThemeSkillsRailState = useWorkbenchStore( - (state) => state.setThemeSkillsRailState, - ); const clearThemeSkillsRailState = useWorkbenchStore( (state) => state.clearThemeSkillsRailState, ); @@ -2073,27 +2108,13 @@ export function AgentChatPage({ }, []); // 主题工作台模式:同步 skills 状态到 store + // 注意:不再设置 themeSkillsRailState,避免"操作面板"覆盖默认 Skills Rail + // 默认 Skills Rail 已包含完整的技能分类(文字多搜索、视觉生成、音频生成等) useEffect(() => { if (!isThemeWorkbench) { clearThemeSkillsRailState(); - return; } - - if (skills.length === 0) { - return; - } - - setThemeSkillsRailState({ - skills, - isAutoRunning: isSending, - }); - }, [ - isThemeWorkbench, - skills, - isSending, - setThemeSkillsRailState, - clearThemeSkillsRailState, - ]); + }, [isThemeWorkbench, clearThemeSkillsRailState]); // 组件卸载时清理 store 状态 useEffect(() => { @@ -2268,6 +2289,68 @@ export function AgentChatPage({ ); }, [isThemeWorkbench, messages]); + // 提取最新的 A2UI Form(从最后一条 assistant 消息的 content 解析) + const pendingA2UIForm = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + + if (msg.role === "user") { + return null; + } + + if (msg.role === "assistant" && msg.content) { + try { + const parsed = parseAIResponse(msg.content, false); + if (parsed.hasA2UI) { + for (let j = parsed.parts.length - 1; j >= 0; j--) { + const part = parsed.parts[j]; + if (part.type === "a2ui" && typeof part.content !== "string") { + return part.content; + } + } + } + } catch { + // 解析失败,忽略 + } + } + } + return null; + }, [messages]); + + + const a2uiSubmissionNotice = useMemo(() => { + if (pendingA2UIForm) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "user") { + continue; + } + + const content = msg.content.trim(); + if (!content.startsWith("我的选择:")) { + return null; + } + + const summary = content + .split("\n") + .slice(1) + .map((line) => line.replace(/^[-•]\s*/, "").trim()) + .filter(Boolean) + .slice(0, 3) + .join(" · "); + + return { + title: "需求已确认", + summary: summary || "已收到你的补充信息,正在继续推进下一步。", + }; + } + + return null; + }, [messages, pendingA2UIForm]); + useEffect(() => { const unsubscribe = subscribeDocumentEditorFocus((focused) => { documentEditorFocusedRef.current = focused; @@ -2934,8 +3017,19 @@ export function AgentChatPage({ // 从会话元数据恢复主题(类型已统一,直接使用) if (sessionMeta.theme && (!lockTheme || !initialTheme)) { - console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme); - setActiveTheme(sessionMeta.theme); + // 通用对话入口(initialTheme 为空或 "general")不应恢复为内容创作主题, + // 避免切换历史话题时错误激活社媒等创作模式 + const entryIsGeneral = !initialTheme || initialTheme === "general"; + const restoredIsCreation = isContentCreationTheme(sessionMeta.theme); + if (entryIsGeneral && restoredIsCreation) { + console.log( + "[AgentChatPage] 通用对话入口,跳过恢复内容创作主题:", + sessionMeta.theme, + ); + } else { + console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme); + setActiveTheme(sessionMeta.theme); + } } // 从会话元数据恢复创建模式 @@ -3021,6 +3115,7 @@ export function AgentChatPage({ processedMessageIds.current.clear(); restoredMetaSessionId.current = null; restoredFilesSessionId.current = null; + hasTriggeredGuide.current = false; }, []); const runTopicSwitch = useCallback( @@ -4646,6 +4741,14 @@ export function AgentChatPage({ [sendMessage], ); + // 包装 A2UI 表单提交,适配 Inputbar 的签名 + const handleInputbarA2UISubmit = useCallback( + (formData: A2UIFormData) => { + void handleA2UISubmit(formData, ""); + }, + [handleA2UISubmit], + ); + // 存储 triggerAIGuide 函数引用,避免在 useEffect 依赖中包含函数 const triggerAIGuideRef = useRef(triggerAIGuide); triggerAIGuideRef.current = triggerAIGuide; @@ -4689,34 +4792,32 @@ export function AgentChatPage({ if (isThemeWorkbench) { hasTriggeredGuide.current = true; - const themeGuide = getDefaultGuidePromptByTheme(activeTheme); - if (themeGuide) { - console.log("[AgentChatPage] 主题工作台:预填引导词"); - setInput((prev) => (prev.trim() ? prev : themeGuide)); - } - // 不自动发送,让用户确认后手动发送 + console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流"); + // 同步创建后端工作流(不阻塞触发) + void (async () => { + try { + const { contentWorkflowApi } = await import("@/lib/api/content-workflow"); + const themeForApi = mappedTheme as import("@/lib/api/content-workflow").ThemeType; + const modeForApi = (creationMode as import("@/lib/api/content-workflow").CreationMode) ?? "guided"; + await contentWorkflowApi.create(contentId!, themeForApi, modeForApi); + console.log("[AgentChatPage] 后端工作流创建成功"); + } catch (e) { + console.warn("[AgentChatPage] 后端工作流创建失败(不影响主流程):", e); + } + })(); + triggerAIGuideRef.current(); return; } hasTriggeredGuide.current = true; - const defaultGuidePrompt = getDefaultGuidePromptByTheme(activeTheme); - if (defaultGuidePrompt) { - console.log("[AgentChatPage] 自动预填主题引导词"); - setInput((previous) => { - if (previous.trim()) { - return previous; - } - return defaultGuidePrompt; - }); - return; - } - console.log("[AgentChatPage] 自动触发 AI 创作引导"); triggerAIGuideRef.current(); } }, [ activeTheme, contentId, + mappedTheme, + creationMode, messages.length, project, systemPrompt, @@ -4734,6 +4835,45 @@ export function AgentChatPage({ hasTriggeredGuide.current = false; }, [contentId]); + // 当 contentId 变化且是主题工作台时,尝试从后端恢复工作流 + useEffect(() => { + if (!contentId || !isThemeWorkbench) return; + + void (async () => { + try { + const { contentWorkflowApi } = await import("@/lib/api/content-workflow"); + const workflow = await contentWorkflowApi.getByContent(contentId); + if (workflow) { + const completedCount = workflow.steps.filter( + (s) => s.status === "completed" || s.status === "skipped", + ).length; + console.log( + `[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`, + ); + } + } catch (e) { + // 查询失败不影响主流程 + console.debug("[AgentChatPage] 查询后端工作流失败:", e); + } + })(); + }, [contentId, isThemeWorkbench]); + + // 监听封面图重新生成成功事件,将占位 URL 替换为真实图片 URL + useEffect(() => { + const handler = (e: Event) => { + const { placeholder, imageUrl } = (e as CustomEvent).detail; + if (!placeholder || !imageUrl) return; + setCanvasState((prev) => { + if (!prev || prev.type !== "document") return prev; + const updatedContent = prev.content.split(placeholder).join(imageUrl); + if (updatedContent === prev.content) return prev; + return { ...prev, content: updatedContent }; + }); + }; + window.addEventListener(COVER_IMAGE_REPLACED_EVENT, handler); + return () => window.removeEventListener(COVER_IMAGE_REPLACED_EVENT, handler); + }, []); + // 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程 const showChatLayout = hasMessages || isThemeWorkbench; const showThemeWorkbenchSidebar = @@ -4793,6 +4933,7 @@ export function AgentChatPage({ activeRunDetail={selectedThemeWorkbenchRunDetail} activeRunDetailLoading={themeWorkbenchRunDetailLoading} onRequestCollapse={themeWorkbenchSidebarCollapseHandler} + messages={messages} /> ); }, [ @@ -4826,6 +4967,7 @@ export function AgentChatPage({ themeWorkbenchRunDetailLoading, themeWorkbenchSidebarCollapseHandler, themeWorkbenchWorkflowSteps, + messages, ]); const workflowProgressSignature = useMemo(() => { @@ -4960,6 +5102,9 @@ export function AgentChatPage({ setInput={setInput} variant={isThemeWorkbench ? "theme_workbench" : "default"} themeWorkbenchGate={isThemeWorkbench ? currentGate : null} + pendingA2UIForm={pendingA2UIForm || null} + onA2UISubmit={handleInputbarA2UISubmit} + a2uiSubmissionNotice={a2uiSubmissionNotice} workflowSteps={isThemeWorkbench ? themeWorkbenchWorkflowSteps : steps} themeWorkbenchRunState={themeWorkbenchRunState} onSend={handleSend} @@ -5023,6 +5168,9 @@ export function AgentChatPage({ taskFilesExpanded, themeWorkbenchRunState, themeWorkbenchWorkflowSteps, + handleInputbarA2UISubmit, + pendingA2UIForm, + a2uiSubmissionNotice, ], ); @@ -5471,7 +5619,9 @@ export function AgentChatPage({ /> {isThemeWorkbench && showChatLayout ? ( - + {inputbarNode} ) : null} @@ -5500,6 +5650,7 @@ export function AgentChatPage({ layoutMode, novelChapterListCollapsed, onBackToProjectManagement, + pendingA2UIForm, projectId, showChatLayout, showChatPanel, diff --git a/src/components/content-creator/canvas/document/platforms/CoverImagePlaceholder.tsx b/src/components/content-creator/canvas/document/platforms/CoverImagePlaceholder.tsx new file mode 100644 index 000000000..cbc2b930a --- /dev/null +++ b/src/components/content-creator/canvas/document/platforms/CoverImagePlaceholder.tsx @@ -0,0 +1,217 @@ +/** + * @file 封面图占位符组件 + * @description 处理图片加载失败或已知占位符 URL,显示样式化的占位卡片 + * @module components/content-creator/canvas/document/platforms/CoverImagePlaceholder + */ + +import React, { useState, memo } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +/** 从 img URL 中提取 multimodel 格式的提示词 */ +function extractPendingPrompt(src: string): string | null { + // 【img:model:prompt】 格式 + const match = src.match(/【img:[^:]+:(.+?)】/); + if (match) { + return match[1].trim(); + } + // pending-cover://model/encoded_prompt 格式 + if (src.startsWith("pending-cover://")) { + const rest = src.slice("pending-cover://".length); + const slashIdx = rest.indexOf("/"); + if (slashIdx !== -1) { + try { + return decodeURIComponent(rest.slice(slashIdx + 1)); + } catch { + return rest.slice(slashIdx + 1); + } + } + } + return null; +} + +/** 是否是已知的占位符 URL */ +function isPlaceholderUrl(src: string): boolean { + if (!src) return true; + if (src === "cover-generation-failed") return true; + if (src.startsWith("【img:")) return true; + if (src.startsWith("pending-cover://")) return true; + return false; +} + +/** 自定义事件名:封面图重新生成成功 */ +export const COVER_IMAGE_REPLACED_EVENT = "proxycast:cover-image-replaced"; + +/** 自定义事件 detail 类型 */ +export interface CoverImageReplacedDetail { + /** 原始占位 src(pending-cover://... 格式) */ + placeholder: string; + /** 新图片 URL */ + imageUrl: string; +} + +interface CoverImagePlaceholderProps { + alt?: string; + src?: string; + className?: string; +} + +export const CoverImagePlaceholder: React.FC = memo( + ({ alt, src, className }) => { + const [failed, setFailed] = useState(false); + const [retrying, setRetrying] = useState(false); + const [retryError, setRetryError] = useState(null); + + const isPlaceholder = isPlaceholderUrl(src || ""); + const pendingPrompt = src ? extractPendingPrompt(src) : null; + const showPlaceholder = isPlaceholder || failed; + const isFailed = src === "cover-generation-failed" || failed; + + const handleRetry = async () => { + if (!pendingPrompt || retrying) return; + setRetrying(true); + setRetryError(null); + try { + const imageUrl = await invoke("social_generate_cover_image_cmd", { + prompt: pendingPrompt, + }); + // 通知顶层组件替换内容 + window.dispatchEvent( + new CustomEvent(COVER_IMAGE_REPLACED_EVENT, { + detail: { placeholder: src || "", imageUrl }, + }), + ); + } catch (err) { + setRetryError(String(err)); + } finally { + setRetrying(false); + } + }; + + if (!showPlaceholder && src) { + return ( + {alt setFailed(true)} + style={{ display: "block", maxWidth: "100%", margin: "1.5em auto", borderRadius: 8 }} + /> + ); + } + + return ( +
+ {/* 图标 */} + + + + + + + {/* 主标签 */} + + {isFailed ? "封面图生成失败" : "封面图待生成"} + + + {/* 提示词 */} + {pendingPrompt && ( + + {pendingPrompt} + + )} + + {/* 错误提示 */} + {retryError && ( + + {retryError} + + )} + + {/* 重新生成按钮 */} + {pendingPrompt && ( + + )} +
+ ); + }, +); + +CoverImagePlaceholder.displayName = "CoverImagePlaceholder"; + +/** + * 预处理 Markdown 内容:将 【img:model:prompt with spaces】 形式的图片 URL + * 转换为合法的 pending-cover://model/encoded_prompt 格式,避免 Markdown 解析器 + * 因 URL 中含空格而无法识别图片语法。 + */ +/* eslint-disable react-refresh/only-export-components */ +export function preprocessCoverImageUrls(content: string): string { + return content.replace( + /!\[([^\]]*)\]\(【img:([^:]+):([^】]*)】\)/g, + (_, imgAlt: string, model: string, prompt: string) => { + const encoded = encodeURIComponent(prompt.trim()); + return `![${imgAlt}](pending-cover://${model}/${encoded})`; + }, + ); +} diff --git a/src/components/content-creator/canvas/document/platforms/MarkdownRenderer.tsx b/src/components/content-creator/canvas/document/platforms/MarkdownRenderer.tsx index d3df8cf18..d627ab8b5 100644 --- a/src/components/content-creator/canvas/document/platforms/MarkdownRenderer.tsx +++ b/src/components/content-creator/canvas/document/platforms/MarkdownRenderer.tsx @@ -10,6 +10,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { CoverImagePlaceholder, preprocessCoverImageUrls } from "./CoverImagePlaceholder"; interface MarkdownRendererProps { content: string; @@ -180,11 +181,16 @@ export const MarkdownRenderer: React.FC = memo( await navigator.clipboard.writeText(code); }; + const processedContent = preprocessCoverImageUrls(content); + return ( ; + }, code({ className, children, ...props }) { const match = /language-(\w+)/.exec(className || ""); const codeString = String(children).replace(/\n$/, ""); @@ -213,7 +219,7 @@ export const MarkdownRenderer: React.FC = memo( }, }} > - {content} + {processedContent} ); diff --git a/src/components/content-creator/canvas/document/platforms/WechatRenderer.tsx b/src/components/content-creator/canvas/document/platforms/WechatRenderer.tsx index 0b587dd14..56e67020c 100644 --- a/src/components/content-creator/canvas/document/platforms/WechatRenderer.tsx +++ b/src/components/content-creator/canvas/document/platforms/WechatRenderer.tsx @@ -8,6 +8,7 @@ import React, { memo } from "react"; import styled from "styled-components"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { CoverImagePlaceholder, preprocessCoverImageUrls } from "./CoverImagePlaceholder"; interface WechatRendererProps { content: string; @@ -130,9 +131,19 @@ const Container = styled.div` */ export const WechatRenderer: React.FC = memo( ({ content }) => { + const processedContent = preprocessCoverImageUrls(content); return ( - {content} + ; + }, + }} + > + {processedContent} + ); }, diff --git a/src/components/content-creator/canvas/document/platforms/XiaohongshuRenderer.tsx b/src/components/content-creator/canvas/document/platforms/XiaohongshuRenderer.tsx index e0877e71a..58747f846 100644 --- a/src/components/content-creator/canvas/document/platforms/XiaohongshuRenderer.tsx +++ b/src/components/content-creator/canvas/document/platforms/XiaohongshuRenderer.tsx @@ -8,6 +8,7 @@ import React, { memo } from "react"; import styled from "styled-components"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { CoverImagePlaceholder, preprocessCoverImageUrls } from "./CoverImagePlaceholder"; interface XiaohongshuRendererProps { content: string; @@ -120,7 +121,8 @@ const Container = styled.div` /* 话题标签高亮样式 */ const processContent = (content: string): string => { // 将 #话题# 格式转换为带样式的 span - return content.replace(/#([^#\s]+)#/g, "**#$1#**"); + const withTags = content.replace(/#([^#\s]+)#/g, "**#$1#**"); + return preprocessCoverImageUrls(withTags); }; /** @@ -132,7 +134,14 @@ export const XiaohongshuRenderer: React.FC = memo( return ( - + ; + }, + }} + > {processedContent} diff --git a/src/hooks/useConfiguredProviders.ts b/src/hooks/useConfiguredProviders.ts index 360347ef9..78d058d1d 100644 --- a/src/hooks/useConfiguredProviders.ts +++ b/src/hooks/useConfiguredProviders.ts @@ -81,10 +81,16 @@ export function useConfiguredProviders(): UseConfiguredProvidersResult { // 计算已配置的 Provider 列表 const providers = useMemo(() => { + const safeOauthCredentials = Array.isArray(oauthCredentials) + ? oauthCredentials + : []; + const safeApiKeyProviders = Array.isArray(apiKeyProviders) + ? apiKeyProviders + : []; const providerMap = new Map(); // 1. 从 OAuth 凭证提取 Provider - oauthCredentials.forEach((overview) => { + safeOauthCredentials.forEach((overview) => { if (overview.credentials.length > 0) { const key = overview.provider_type; const firstCredential = overview.credentials[0]; @@ -105,7 +111,7 @@ export function useConfiguredProviders(): UseConfiguredProvidersResult { // 2. 从 API Key Provider 提取 // 使用 provider.id 作为 key,确保每个 Provider 单独显示 // 特殊处理:如果与 OAuth 凭证冲突,使用带后缀的 key - apiKeyProviders + safeApiKeyProviders .filter((p) => p.api_key_count > 0 && p.enabled) .forEach((provider) => { let key = provider.id; diff --git a/src/lib/api/agent.ts b/src/lib/api/agent.ts index 3bd037dbe..6c86e35ec 100644 --- a/src/lib/api/agent.ts +++ b/src/lib/api/agent.ts @@ -836,6 +836,7 @@ export async function sendAsterMessageStream( executionStrategy?: AsterExecutionStrategy, webSearch?: boolean, autoContinue?: AutoContinueRequestPayload, + systemPrompt?: string, ): Promise { const resolvedWorkspaceId = requireWorkspaceId(workspaceId); @@ -850,6 +851,7 @@ export async function sendAsterMessageStream( execution_strategy: executionStrategy, web_search: webSearch, auto_continue: autoContinue, + system_prompt: systemPrompt, }, }); } diff --git a/src/lib/api/content-workflow.ts b/src/lib/api/content-workflow.ts new file mode 100644 index 000000000..b511a6e9e --- /dev/null +++ b/src/lib/api/content-workflow.ts @@ -0,0 +1,211 @@ +/** + * 内容创作工作流 API + * + * 封装所有工作流相关的 Tauri 命令调用 + */ + +import { invoke } from '@tauri-apps/api/core'; + +/** + * 步骤状态 + */ +export type StepStatus = 'pending' | 'active' | 'completed' | 'skipped' | 'error'; + +/** + * 步骤类型 + */ +export type StepType = 'clarify' | 'research' | 'outline' | 'write' | 'polish' | 'adapt'; + +/** + * 主题类型 + */ +export type ThemeType = + | 'general' + | 'knowledge' + | 'planning' + | 'social-media' + | 'poster' + | 'document' + | 'paper' + | 'novel' + | 'script' + | 'music' + | 'video'; + +/** + * 创作模式 + */ +export type CreationMode = 'guided' | 'fast' | 'hybrid' | 'framework'; + +/** + * 步骤行为配置 + */ +export interface StepBehavior { + skippable: boolean; + redoable: boolean; + auto_advance: boolean; +} + +/** + * 表单字段类型 + */ +export type FormFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'slider' | 'tags' | 'outline'; + +/** + * 表单字段选项 + */ +export interface FormFieldOption { + label: string; + value: string; +} + +/** + * 表单字段定义 + */ +export interface FormField { + name: string; + label: string; + type: FormFieldType; + required: boolean; + placeholder?: string; + options?: FormFieldOption[]; + default_value?: any; +} + +/** + * 表单配置 + */ +export interface FormConfig { + fields: FormField[]; + submit_label: string; + skip_label?: string; +} + +/** + * AI 任务配置 + */ +export interface AITaskConfig { + task_type: string; + prompt?: string; + streaming: boolean; +} + +/** + * 内容文件 + */ +export interface ContentFile { + id: string; + name: string; + type: string; + content?: string; + created_at: number; + updated_at: number; + thumbnail?: string; + metadata?: Record; +} + +/** + * 步骤结果 + */ +export interface StepResult { + user_input?: Record; + ai_output?: any; + artifacts?: ContentFile[]; +} + +/** + * 步骤定义 + */ +export interface StepDefinition { + id: string; + type: StepType; + title: string; + description?: string; + form?: FormConfig; + ai_task?: AITaskConfig; + behavior: StepBehavior; +} + +/** + * 工作流步骤(运行时状态) + */ +export interface WorkflowStep extends StepDefinition { + status: StepStatus; + result?: StepResult; +} + +/** + * 工作流状态 + */ +export interface WorkflowState { + id: string; + content_id: string; + theme: ThemeType; + mode: CreationMode; + steps: WorkflowStep[]; + current_step_index: number; + created_at: number; + updated_at: number; +} + +/** + * 内容创作工作流 API + */ +export const contentWorkflowApi = { + /** + * 创建工作流 + */ + async create(contentId: string, theme: ThemeType, mode: CreationMode): Promise { + return invoke('content_workflow_create', { + contentId, + theme, + mode, + }); + }, + + /** + * 获取工作流 + */ + async get(workflowId: string): Promise { + return invoke('content_workflow_get', { + workflowId, + }); + }, + + /** + * 根据 content_id 获取工作流 + */ + async getByContent(contentId: string): Promise { + return invoke('content_workflow_get_by_content', { + contentId, + }); + }, + + /** + * 推进工作流(完成当前步骤) + */ + async advance(workflowId: string, stepResult: StepResult): Promise { + return invoke('content_workflow_advance', { + workflowId, + stepResult, + }); + }, + + /** + * 重试失败的步骤 + */ + async retry(workflowId: string): Promise { + return invoke('content_workflow_retry', { + workflowId, + }); + }, + + /** + * 取消工作流 + */ + async cancel(workflowId: string): Promise { + return invoke('content_workflow_cancel', { + workflowId, + }); + }, +}; diff --git a/src/lib/dev-bridge/http-client.ts b/src/lib/dev-bridge/http-client.ts index 7b2340d28..55f2f617a 100644 --- a/src/lib/dev-bridge/http-client.ts +++ b/src/lib/dev-bridge/http-client.ts @@ -6,6 +6,7 @@ */ const BRIDGE_URL = "http://127.0.0.1:3030/invoke"; +const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health"; export interface InvokeRequest { cmd: string; @@ -17,6 +18,50 @@ export interface InvokeResponse { error?: string; } +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message || error.name || "Unknown error"; + } + return String(error || "Unknown error"); +} + +function isBridgeCommandError(message: string): boolean { + return ( + message.includes("未知命令") || + message.includes("Unsupported command") || + message.includes("未实现") + ); +} + +function isBridgeConnectionError(message: string): boolean { + return ( + message.includes("Failed to fetch") || + message.includes("fetch failed") || + message.includes("NetworkError") || + message.includes("ERR_CONNECTION_REFUSED") || + message.includes("Load failed") || + message.includes("ECONNREFUSED") + ); +} + +export function normalizeDevBridgeError(cmd: string, error: unknown): Error { + const message = toErrorMessage(error); + + if (isBridgeCommandError(message)) { + return error instanceof Error ? error : new Error(message); + } + + if (isBridgeConnectionError(message)) { + return new Error( + `[DevBridge] 浏览器模式无法连接后端桥接,命令 "${cmd}" 执行失败。请先启动 Tauri 开发后端(例如 npm run tauri:dev 或 npm run tauri:dev:headless),并确认 http://127.0.0.1:3030 可访问。原始错误: ${message}`, + ); + } + + return error instanceof Error + ? error + : new Error(`[DevBridge] 命令 "${cmd}" 调用失败: ${message}`); +} + /** * 检查开发桥接是否可用 * @@ -81,14 +126,8 @@ export async function invokeViaHttp( */ export async function healthCheck(): Promise { try { - const response = await fetch(BRIDGE_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - cmd: "get_server_status", - } satisfies InvokeRequest), + const response = await fetch(BRIDGE_HEALTH_URL, { + method: "GET", }); return response.ok; } catch { diff --git a/src/lib/dev-bridge/mockPriorityCommands.test.ts b/src/lib/dev-bridge/mockPriorityCommands.test.ts new file mode 100644 index 000000000..1986cfe21 --- /dev/null +++ b/src/lib/dev-bridge/mockPriorityCommands.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { shouldPreferMockInBrowser } from "./mockPriorityCommands"; + +describe("mockPriorityCommands", () => { + it("工作台阶段缺失桥接命令优先走 mock", () => { + expect( + shouldPreferMockInBrowser("execution_run_get_theme_workbench_state"), + ).toBe(true); + expect(shouldPreferMockInBrowser("aster_agent_chat_stream")).toBe(true); + expect(shouldPreferMockInBrowser("get_hint_routes")).toBe(true); + expect(shouldPreferMockInBrowser("content_workflow_get_by_content")).toBe(true); + }); +}); diff --git a/src/lib/dev-bridge/mockPriorityCommands.ts b/src/lib/dev-bridge/mockPriorityCommands.ts new file mode 100644 index 000000000..4b946b46d --- /dev/null +++ b/src/lib/dev-bridge/mockPriorityCommands.ts @@ -0,0 +1,37 @@ +/** + * 浏览器模式下优先走 mock 的命令集合。 + * + * 这些命令要么依赖当前 DevBridge 尚未桥接的原生能力, + * 要么即使缺少真实后端也不应阻塞默认页面渲染。 + */ + +const mockPriorityCommands = new Set([ + "aster_agent_init", + "connection_list", + "terminal_create_session", + "list_dir", + "get_plugins_with_ui", + "get_plugin_status", + "get_plugins", + "list_installed_plugins", + "list_plugin_tasks", + "get_plugin_queue_stats", + "subscribe_sysinfo", + "unsubscribe_sysinfo", + "session_files_get_or_create", + "session_files_update_meta", + "session_files_list_files", + "session_files_save_file", + "session_files_read_file", + "session_files_delete_file", + "execution_run_get_theme_workbench_state", + "aster_agent_chat_stream", + "get_hint_routes", + "content_workflow_get_by_content", +]); + +export function shouldPreferMockInBrowser(cmd: string): boolean { + return mockPriorityCommands.has(cmd); +} + +export { mockPriorityCommands }; diff --git a/src/lib/dev-bridge/safeInvoke.test.ts b/src/lib/dev-bridge/safeInvoke.test.ts new file mode 100644 index 000000000..25b06a05a --- /dev/null +++ b/src/lib/dev-bridge/safeInvoke.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + baseInvoke: vi.fn(), + baseListen: vi.fn(), + baseEmit: vi.fn(), + invokeViaHttp: vi.fn(), + isDevBridgeAvailable: vi.fn(), + normalizeDevBridgeError: vi.fn((cmd: string, error: unknown) => { + if (error instanceof Error) { + return new Error(`[${cmd}] ${error.message}`); + } + return new Error(`[${cmd}] ${String(error)}`); + }), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.baseInvoke, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: mocks.baseListen, + emit: mocks.baseEmit, +})); + +vi.mock("./http-client", () => ({ + invokeViaHttp: mocks.invokeViaHttp, + isDevBridgeAvailable: mocks.isDevBridgeAvailable, + normalizeDevBridgeError: mocks.normalizeDevBridgeError, +})); + +vi.mock("./mockPriorityCommands", () => ({ + shouldPreferMockInBrowser: vi.fn(() => false), +})); + +import { safeInvoke } from "./safeInvoke"; +import { shouldPreferMockInBrowser } from "./mockPriorityCommands"; + +describe("safeInvoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isDevBridgeAvailable.mockReturnValue(true); + window.localStorage.clear(); + delete (window as any).__TAURI__; + }); + + it("浏览器开发模式下优先走 HTTP bridge", async () => { + mocks.invokeViaHttp.mockResolvedValueOnce({ ok: true }); + + const result = await safeInvoke("workspace_list"); + + expect(result).toEqual({ ok: true }); + expect(mocks.invokeViaHttp).toHaveBeenCalledWith("workspace_list", undefined); + expect(mocks.baseInvoke).not.toHaveBeenCalled(); + }); + + it("HTTP bridge 失败时会回退到 mock/baseInvoke", async () => { + mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch")); + mocks.baseInvoke.mockResolvedValueOnce(["mocked"]); + + await expect(safeInvoke("workspace_list")).resolves.toEqual(["mocked"]); + + expect(mocks.normalizeDevBridgeError).toHaveBeenCalled(); + expect(mocks.baseInvoke).toHaveBeenCalledWith("workspace_list", undefined); + }); + + it("mock 优先命令会直接走 fallback invoke", async () => { + vi.mocked(shouldPreferMockInBrowser).mockReturnValueOnce(true); + mocks.baseInvoke.mockResolvedValueOnce(["mock-first"]); + + await expect(safeInvoke("list_plugin_tasks")).resolves.toEqual(["mock-first"]); + + expect(mocks.invokeViaHttp).not.toHaveBeenCalled(); + expect(mocks.baseInvoke).toHaveBeenCalledWith("list_plugin_tasks", undefined); + }); + + it("HTTP bridge 与 mock 都失败时抛出 bridge 错误", async () => { + mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch")); + mocks.baseInvoke.mockRejectedValueOnce(new Error("mock failed")); + + await expect(safeInvoke("workspace_list")).rejects.toThrow( + "[workspace_list] Failed to fetch", + ); + }); +}); diff --git a/src/lib/dev-bridge/safeInvoke.ts b/src/lib/dev-bridge/safeInvoke.ts index f8162b9fc..cecc92d74 100644 --- a/src/lib/dev-bridge/safeInvoke.ts +++ b/src/lib/dev-bridge/safeInvoke.ts @@ -3,7 +3,7 @@ * @description 提供安全的 Tauri invoke 调用,支持三层 fallback: * 1. Tauri IPC (生产环境或 Tauri webview) * 2. HTTP Bridge (开发模式,浏览器 + Tauri 后端) - * 3. Mock (纯浏览器开发) + * 3. Mock (仅测试/非浏览器调试场景) * * @module dev-bridge/safeInvoke */ @@ -11,7 +11,12 @@ import { invoke as baseInvoke } from "@tauri-apps/api/core"; import { listen as baseListen, emit as baseEmit } from "@tauri-apps/api/event"; import type { UnlistenFn } from "@tauri-apps/api/event"; -import { invokeViaHttp, isDevBridgeAvailable } from "./http-client"; +import { + invokeViaHttp, + isDevBridgeAvailable, + normalizeDevBridgeError, +} from "./http-client"; +import { shouldPreferMockInBrowser } from "./mockPriorityCommands"; export interface InvokeErrorBufferEntry { timestamp: string; @@ -162,7 +167,8 @@ export function clearInvokeErrorBuffer(): void { /** * 安全的 Tauri invoke 封装 - * 支持三种模式:Tauri IPC → HTTP Bridge → Mock + * 支持三种模式:Tauri IPC → HTTP Bridge → Mock。 + * 在浏览器开发模式下,HTTP Bridge 失败会直接报错,不再静默回退到 mock。 */ export async function safeInvoke( cmd: string, @@ -191,18 +197,35 @@ export async function safeInvoke( } } - // 2. Dev 模式下尝试 HTTP 桥接(浏览器环境,Tauri 后端在运行) + // 2. 浏览器开发模式下,部分原生/非关键命令直接优先走 mock。 + if (isDevBridgeAvailable() && shouldPreferMockInBrowser(cmd)) { + try { + return await baseInvoke(cmd, args); + } catch (error) { + recordInvokeError(cmd, args, error, "fallback-invoke"); + throw error; + } + } + + // 3. Dev 模式下尝试 HTTP 桥接(浏览器环境,Tauri 后端在运行) if (isDevBridgeAvailable()) { try { const result = await invokeViaHttp(cmd, args); return result as T; } catch (error) { - recordInvokeError(cmd, args, error, "http-bridge"); - // 继续尝试 mock + const normalizedError = normalizeDevBridgeError(cmd, error); + recordInvokeError(cmd, args, normalizedError, "http-bridge"); + + try { + return await baseInvoke(cmd, args); + } catch (fallbackError) { + recordInvokeError(cmd, args, fallbackError, "fallback-invoke"); + throw normalizedError; + } } } - // 3. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入) + // 4. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入) try { return await baseInvoke(cmd, args); } catch (error) { diff --git a/src/lib/tauri-mock/core.test.ts b/src/lib/tauri-mock/core.test.ts new file mode 100644 index 000000000..0401ea1d5 --- /dev/null +++ b/src/lib/tauri-mock/core.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invokeViaHttp: vi.fn(), + isDevBridgeAvailable: vi.fn(), + normalizeDevBridgeError: vi.fn((cmd: string, error: unknown) => { + if (error instanceof Error) { + return new Error(`[${cmd}] ${error.message}`); + } + return new Error(`[${cmd}] ${String(error)}`); + }), +})); + +vi.mock("../dev-bridge/http-client", () => ({ + invokeViaHttp: mocks.invokeViaHttp, + isDevBridgeAvailable: mocks.isDevBridgeAvailable, + normalizeDevBridgeError: mocks.normalizeDevBridgeError, +})); + +vi.mock("../dev-bridge/mockPriorityCommands", () => ({ + shouldPreferMockInBrowser: vi.fn(() => false), +})); + +import { shouldPreferMockInBrowser } from "../dev-bridge/mockPriorityCommands"; +import { clearMocks, invoke } from "./core"; + +describe("tauri-mock/core invoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearMocks(); + mocks.isDevBridgeAvailable.mockReturnValue(true); + }); + + it("浏览器模式下 direct invoke 走 HTTP bridge", async () => { + mocks.invokeViaHttp.mockResolvedValueOnce("/real/backend/root"); + + const result = await invoke("workspace_get_projects_root"); + + expect(result).toBe("/real/backend/root"); + expect(mocks.invokeViaHttp).toHaveBeenCalledWith( + "workspace_get_projects_root", + undefined, + ); + }); + + it("mock 优先命令直接返回默认 mock,不访问 bridge", async () => { + vi.mocked(shouldPreferMockInBrowser).mockReturnValueOnce(true); + + await expect(invoke("list_plugin_tasks", { taskState: null, limit: 300 })).resolves.toEqual([]); + + expect(mocks.invokeViaHttp).not.toHaveBeenCalled(); + }); + + it("bridge 失败且命令存在 mock 时回退默认 mock 数据", async () => { + mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch")); + + await expect(invoke("workspace_get_projects_root")).resolves.toBe( + "/mock/workspace/projects", + ); + }); +}); diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 35bb9fc2c..5121230da 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -2,9 +2,15 @@ * Mock for @tauri-apps/api/core */ +import { + invokeViaHttp, + isDevBridgeAvailable, + normalizeDevBridgeError, +} from "../dev-bridge/http-client"; +import { shouldPreferMockInBrowser } from "../dev-bridge/mockPriorityCommands"; + // 模拟的命令处理器 const mockCommands = new Map any>(); - // 默认 mock 数据 const defaultMocks: Record = { // 配置相关 @@ -149,6 +155,7 @@ const defaultMocks: Record = { return provider; }, get_available_models: () => [], + get_hint_routes: () => [], // 服务器相关 get_server_status: () => ({ @@ -617,6 +624,54 @@ const defaultMocks: Record = { id: "mock-material-id", }), + list_materials: () => [], + project_memory_get: () => ({ + characters: [], + world_building: null, + style_guide: null, + outline: [], + }), + get_conversation_memory_overview: () => ({ + stats: { total_entries: 0, storage_used: 0, memory_count: 0 }, + entries: [], + }), + get_conversation_memory_stats: () => ({ + total_entries: 0, + storage_used: 0, + memory_count: 0, + }), + + session_files_get_or_create: (args: any) => ({ + sessionId: args?.sessionId ?? "mock-session", + title: "", + theme: null, + creationMode: null, + createdAt: Date.now(), + updatedAt: Date.now(), + fileCount: 0, + totalSize: 0, + }), + session_files_update_meta: (args: any) => ({ + sessionId: args?.sessionId ?? "mock-session", + title: args?.title ?? "", + theme: args?.theme ?? null, + creationMode: args?.creationMode ?? null, + createdAt: Date.now(), + updatedAt: Date.now(), + fileCount: 0, + totalSize: 0, + }), + session_files_list_files: () => [], + session_files_save_file: (args: any) => ({ + name: args?.fileName ?? "mock.txt", + fileType: "text/plain", + size: typeof args?.content === "string" ? args.content.length : 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }), + session_files_read_file: () => "", + session_files_delete_file: () => undefined, + // OAuth 凭证相关 add_kiro_oauth_credential: () => ({ success: true }), add_kiro_from_json: () => ({ success: true }), @@ -1060,6 +1115,7 @@ const defaultMocks: Record = { latest_terminal: null, updated_at: new Date().toISOString(), }), + content_workflow_get_by_content: () => null, content_get_theme_workbench_document_state: () => null, // Workspace 相关 @@ -1108,6 +1164,18 @@ export async function invoke( return handler(args); } + if (isDevBridgeAvailable() && !shouldPreferMockInBrowser(cmd)) { + try { + return await invokeViaHttp(cmd, args); + } catch (error) { + if (cmd in defaultMocks) { + console.warn(`[Mock] Bridge unavailable or unsupported, fallback to mock: ${cmd}`); + return defaultMocks[cmd](args); + } + throw normalizeDevBridgeError(cmd, error); + } + } + // 使用默认 mock if (cmd in defaultMocks) { return defaultMocks[cmd](args); diff --git a/vite.config.ts b/vite.config.ts index 99ff3868f..8fc5ac11d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,9 @@ const tauriMockDir = path.resolve(__dirname, "./src/lib/tauri-mock"); export default defineConfig(({ mode }) => { // 检查是否在 Tauri 环境中运行(通过环境变量判断) - const isTauri = process.env.TAURI_ENV_PLATFORM !== undefined; + const isTauri = + process.env.TAURI_ENV_PLATFORM !== undefined && + process.env.PROXYCAST_BROWSER_BRIDGE !== "1"; // 避免 Tauri/非 Tauri 共享同一份 optimize deps 缓存导致 chunk 丢失 const cacheDir = isTauri ? "node_modules/.vite-tauri" : "node_modules/.vite-web";