From 174ae555db8d0828cfe57bd5f917ae95de6f45f2 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 4 Feb 2026 20:34:49 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=20CI=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E6=97=B6=E9=97=B4=20v0.57.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - whisper-rs 改为可选依赖 (local-whisper feature) - 禁用 LTO (CARGO_PROFILE_RELEASE_LTO=off) - 增加 codegen-units 到 32 - 移除构建后清理步骤 - 统一内容创作系统增强 Co-Authored-By: Warp --- .github/workflows/release.yml | 38 +- IMPLEMENTATION_PLAN.md | 105 ++ docs/LLM_FLOW_MONITOR_SPEC.md | 1565 ----------------- docs/design/a2ui-persistence.md | 186 ++ package.json | 2 +- src-tauri/Cargo.lock | 32 +- src-tauri/Cargo.toml | 6 +- src-tauri/crates/voice-core/Cargo.toml | 9 +- src-tauri/crates/voice-core/src/lib.rs | 2 + src-tauri/src/app/runner.rs | 25 + src-tauri/src/commands/a2ui_form_cmd.rs | 107 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/persona_cmd.rs | 374 +++- src-tauri/src/commands/poster_material_cmd.rs | 188 ++ src-tauri/src/database/dao/a2ui_form_dao.rs | 280 +++ .../src/database/dao/brand_persona_dao.rs | 688 ++++++++ src-tauri/src/database/dao/mod.rs | 3 + .../src/database/dao/poster_material_dao.rs | 724 ++++++++ src-tauri/src/database/schema.rs | 105 ++ src-tauri/src/models/project_model.rs | 863 +++++++++ src-tauri/src/services/persona_service.rs | 287 ++- src-tauri/src/voice/asr_service.rs | 18 +- src-tauri/tauri.conf.json | 2 +- .../agent/chat/components/MessageList.tsx | 9 + .../chat/components/StreamingRenderer.tsx | 12 + .../agent/chat/hooks/useAgentChat.ts | 88 +- src/components/content-creator/a2ui/README.md | 43 +- .../a2ui/components/ComponentRenderer.tsx | 131 ++ .../content-creator/a2ui/components/README.md | 54 + .../a2ui/components/display/Button.tsx | 82 + .../a2ui/components/display/Text.tsx | 32 + .../a2ui/components/display/index.ts | 6 + .../a2ui/components/form/CheckBox.tsx | 38 + .../a2ui/components/form/ChoicePicker.tsx | 81 + .../a2ui/components/form/Slider.tsx | 55 + .../a2ui/components/form/TextField.tsx | 58 + .../a2ui/components/form/index.ts | 8 + .../content-creator/a2ui/components/index.tsx | 604 +------ .../a2ui/components/layout/Card.tsx | 47 + .../a2ui/components/layout/Column.tsx | 78 + .../a2ui/components/layout/Divider.tsx | 23 + .../a2ui/components/layout/Row.tsx | 78 + .../a2ui/components/layout/index.ts | 8 + src/components/content-creator/a2ui/index.ts | 2 +- src/components/content-creator/a2ui/types.ts | 497 +++++- .../content-creator/agents/AgentChatPanel.tsx | 600 +++++++ .../agents/AgentScheduler.test.ts | 143 ++ .../content-creator/agents/AgentScheduler.ts | 159 ++ .../content-creator/agents/base/BaseAgent.ts | 125 ++ .../content-creator/agents/base/index.ts | 8 + .../content-creator/agents/base/types.test.ts | 180 ++ .../content-creator/agents/base/types.ts | 352 ++++ .../content-creator/agents/index.ts | 27 + .../agents/poster/ContentAgent.ts | 199 +++ .../agents/poster/ExportAgent.ts | 168 ++ .../agents/poster/LayoutAgent.ts | 377 ++++ .../agents/poster/RefineAgent.ts | 188 ++ .../agents/poster/RequirementAgent.ts | 102 ++ .../agents/poster/StyleAgent.ts | 263 +++ .../content-creator/agents/poster/index.ts | 53 + .../canvas/poster/PosterDesigner.tsx | 443 +++++ .../canvas/poster/hooks/index.ts | 6 + .../poster/hooks/useAgentIntegration.ts | 184 ++ .../poster/hooks/useCanvasAgentBridge.ts | 452 +++++ .../canvas/poster/platforms/douyin.ts | 92 + .../canvas/poster/platforms/index.test.ts | 239 +++ .../canvas/poster/platforms/index.ts | 101 ++ .../canvas/poster/platforms/taobao.ts | 99 ++ .../canvas/poster/platforms/types.ts | 158 ++ .../canvas/poster/platforms/wechat.ts | 98 ++ .../canvas/poster/platforms/xiaohongshu.ts | 84 + .../canvas/poster/utils/index.ts | 25 + .../canvas/poster/utils/safeZone.test.ts | 271 +++ .../canvas/poster/utils/safeZone.ts | 317 ++++ .../canvas/poster/utils/smartCrop.test.ts | 251 +++ .../canvas/poster/utils/smartCrop.ts | 265 +++ .../content-creator/material/ImageGallery.tsx | 345 ++++ .../content-creator/material/index.ts | 8 + .../workflows/poster/PosterWorkflowPanel.tsx | 548 ++++++ .../workflows/poster/brand-image.ts | 229 +++ .../workflows/poster/ecommerce-promo.ts | 186 ++ .../workflows/poster/index.test.ts | 251 +++ .../content-creator/workflows/poster/index.ts | 93 + .../workflows/poster/social-media.ts | 208 +++ .../workflows/poster/types.test.ts | 212 +++ .../content-creator/workflows/poster/types.ts | 244 +++ .../projects/dialogs/BrandPersonaDialog.tsx | 838 +++++++++ .../dialogs/MaterialPreviewDialog.tsx | 210 +++ .../projects/dialogs/PersonaDialog.tsx | 84 +- src/components/projects/dialogs/index.ts | 2 + src/components/projects/tabs/MaterialTab.tsx | 28 +- src/hooks/index.ts | 4 + src/hooks/useBrandPersona.ts | 188 ++ src/hooks/useMultiPlatformExport.ts | 284 +++ src/hooks/usePosterMaterial.ts | 277 +++ src/hooks/usePosterWorkflow.ts | 320 ++++ src/lib/api/a2uiForm.ts | 130 ++ src/types/brand-persona.ts | 504 ++++++ src/types/index.ts | 6 + src/types/material.ts | 13 +- src/types/poster-material.ts | 322 ++++ 101 files changed, 16991 insertions(+), 2217 deletions(-) create mode 100644 IMPLEMENTATION_PLAN.md delete mode 100644 docs/LLM_FLOW_MONITOR_SPEC.md create mode 100644 docs/design/a2ui-persistence.md create mode 100644 src-tauri/src/commands/a2ui_form_cmd.rs create mode 100644 src-tauri/src/commands/poster_material_cmd.rs create mode 100644 src-tauri/src/database/dao/a2ui_form_dao.rs create mode 100644 src-tauri/src/database/dao/brand_persona_dao.rs create mode 100644 src-tauri/src/database/dao/poster_material_dao.rs create mode 100644 src/components/content-creator/a2ui/components/ComponentRenderer.tsx create mode 100644 src/components/content-creator/a2ui/components/README.md create mode 100644 src/components/content-creator/a2ui/components/display/Button.tsx create mode 100644 src/components/content-creator/a2ui/components/display/Text.tsx create mode 100644 src/components/content-creator/a2ui/components/display/index.ts create mode 100644 src/components/content-creator/a2ui/components/form/CheckBox.tsx create mode 100644 src/components/content-creator/a2ui/components/form/ChoicePicker.tsx create mode 100644 src/components/content-creator/a2ui/components/form/Slider.tsx create mode 100644 src/components/content-creator/a2ui/components/form/TextField.tsx create mode 100644 src/components/content-creator/a2ui/components/form/index.ts create mode 100644 src/components/content-creator/a2ui/components/layout/Card.tsx create mode 100644 src/components/content-creator/a2ui/components/layout/Column.tsx create mode 100644 src/components/content-creator/a2ui/components/layout/Divider.tsx create mode 100644 src/components/content-creator/a2ui/components/layout/Row.tsx create mode 100644 src/components/content-creator/a2ui/components/layout/index.ts create mode 100644 src/components/content-creator/agents/AgentChatPanel.tsx create mode 100644 src/components/content-creator/agents/AgentScheduler.test.ts create mode 100644 src/components/content-creator/agents/AgentScheduler.ts create mode 100644 src/components/content-creator/agents/base/BaseAgent.ts create mode 100644 src/components/content-creator/agents/base/index.ts create mode 100644 src/components/content-creator/agents/base/types.test.ts create mode 100644 src/components/content-creator/agents/base/types.ts create mode 100644 src/components/content-creator/agents/index.ts create mode 100644 src/components/content-creator/agents/poster/ContentAgent.ts create mode 100644 src/components/content-creator/agents/poster/ExportAgent.ts create mode 100644 src/components/content-creator/agents/poster/LayoutAgent.ts create mode 100644 src/components/content-creator/agents/poster/RefineAgent.ts create mode 100644 src/components/content-creator/agents/poster/RequirementAgent.ts create mode 100644 src/components/content-creator/agents/poster/StyleAgent.ts create mode 100644 src/components/content-creator/agents/poster/index.ts create mode 100644 src/components/content-creator/canvas/poster/PosterDesigner.tsx create mode 100644 src/components/content-creator/canvas/poster/hooks/useAgentIntegration.ts create mode 100644 src/components/content-creator/canvas/poster/hooks/useCanvasAgentBridge.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/douyin.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/index.test.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/index.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/taobao.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/types.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/wechat.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/xiaohongshu.ts create mode 100644 src/components/content-creator/canvas/poster/utils/safeZone.test.ts create mode 100644 src/components/content-creator/canvas/poster/utils/safeZone.ts create mode 100644 src/components/content-creator/canvas/poster/utils/smartCrop.test.ts create mode 100644 src/components/content-creator/canvas/poster/utils/smartCrop.ts create mode 100644 src/components/content-creator/material/ImageGallery.tsx create mode 100644 src/components/content-creator/material/index.ts create mode 100644 src/components/content-creator/workflows/poster/PosterWorkflowPanel.tsx create mode 100644 src/components/content-creator/workflows/poster/brand-image.ts create mode 100644 src/components/content-creator/workflows/poster/ecommerce-promo.ts create mode 100644 src/components/content-creator/workflows/poster/index.test.ts create mode 100644 src/components/content-creator/workflows/poster/index.ts create mode 100644 src/components/content-creator/workflows/poster/social-media.ts create mode 100644 src/components/content-creator/workflows/poster/types.test.ts create mode 100644 src/components/content-creator/workflows/poster/types.ts create mode 100644 src/components/projects/dialogs/BrandPersonaDialog.tsx create mode 100644 src/components/projects/dialogs/MaterialPreviewDialog.tsx create mode 100644 src/hooks/useBrandPersona.ts create mode 100644 src/hooks/useMultiPlatformExport.ts create mode 100644 src/hooks/usePosterMaterial.ts create mode 100644 src/hooks/usePosterWorkflow.ts create mode 100644 src/lib/api/a2uiForm.ts create mode 100644 src/types/brand-persona.ts create mode 100644 src/types/poster-material.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 681fb4309..0535bab5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,8 +116,10 @@ jobs: uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_PROFILE_RELEASE_LTO: thin - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16 + # 禁用 LTO 加速编译(正式发布可改为 thin) + CARGO_PROFILE_RELEASE_LTO: "off" + # 增加并行编译单元 + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32 CARGO_INCREMENTAL: 0 SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: sccache @@ -147,6 +149,7 @@ jobs: - **API Key**: 首次启动自动生成,可在设置页查看/修改 releaseDraft: false prerelease: false + # 默认不启用 voice feature(包含 whisper-rs,编译很慢) args: --target ${{ matrix.target }} - name: Build Tauri app (macOS/Windows) @@ -154,8 +157,10 @@ jobs: uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_PROFILE_RELEASE_LTO: thin - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16 + # 禁用 LTO 加速编译(正式发布可改为 thin) + CARGO_PROFILE_RELEASE_LTO: "off" + # 增加并行编译单元 + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32 CARGO_INCREMENTAL: 0 SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: sccache @@ -192,29 +197,12 @@ jobs: - **API Key**: 首次启动自动生成,可在设置页查看/修改 releaseDraft: false prerelease: false + # 默认不启用 voice feature(包含 whisper-rs,编译很慢) args: --target ${{ matrix.target }} - # 构建后清理,节省缓存空间 - - name: Post-build cleanup (Unix) - if: matrix.platform != 'windows-latest' - run: | - rm -rf src-tauri/target/release/deps || true - rm -rf src-tauri/target/release/build || true - rm -rf src-tauri/target/release/incremental || true - rm -rf src-tauri/target/${{ matrix.target }}/release/deps || true - rm -rf src-tauri/target/${{ matrix.target }}/release/build || true - rm -rf src-tauri/target/${{ matrix.target }}/release/incremental || true - - - name: Post-build cleanup (Windows) - if: matrix.platform == 'windows-latest' - shell: pwsh - run: | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/release/deps - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/release/build - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/release/incremental - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/${{ matrix.target }}/release/deps - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/${{ matrix.target }}/release/build - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/${{ matrix.target }}/release/incremental + # 注意:移除了 Post-build cleanup 步骤 + # 之前的清理会删除 deps/build/incremental 目录,导致缓存无法复用 + # 保留这些文件可以让 rust-cache 更好地工作 - name: Show sccache stats run: sccache --show-stats diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..d7f5bbc67 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,105 @@ +# 图文海报功能实现计划 + +## 当前进度 + +### Phase 1: 品牌人设系统扩展 ✅ 完成 +**Goal**: 扩展现有人设系统,支持海报设计专用字段(配色、字体、品牌调性) +**Status**: Complete + +#### 已完成任务 +- [x] 1.1 扩展数据模型 (`project_model.rs`) + - 新增 BrandPersonality, DesignStyle 枚举 + - 新增 ColorScheme, Typography, LogoPlacement, ImageStyle, IconStyle 结构体 + - 新增 BrandTone, DesignConfig, VisualConfig 结构体 + - 新增 BrandPersonaExtension, BrandPersona, BrandPersonaTemplate 结构体 + - 新增相关请求类型 + +- [x] 1.2 扩展数据库 Schema (`schema.rs`) + - 新增 `brand_persona_extensions` 表 + - 包含 persona_id, brand_tone_json, design_json, visual_json 字段 + +- [x] 1.3 创建 BrandPersona DAO (`brand_persona_dao.rs`) + - 实现 create, get, update, delete 方法 + - 实现 get_brand_persona 获取完整品牌人设 + - 实现 list_templates 获取预设模板 + +- [x] 1.4 扩展 PersonaService (`persona_service.rs`) + - 新增 get_brand_persona, get_brand_extension 方法 + - 新增 save_brand_extension, update_brand_extension 方法 + - 新增 delete_brand_extension, list_brand_persona_templates 方法 + +- [x] 1.5 扩展 Tauri 命令 (`persona_cmd.rs`) + - 新增 get_brand_persona, get_brand_extension 命令 + - 新增 save_brand_extension, update_brand_extension 命令 + - 新增 delete_brand_extension, list_brand_persona_templates 命令 + - 在 runner.rs 中注册新命令 + +- [x] 1.6 新增前端类型 (`brand-persona.ts`) + - 定义所有品牌人设相关的 TypeScript 类型 + - 包含预设配色方案、字体列表、默认值等常量 + +- [x] 1.7 新增 useBrandPersona Hook (`useBrandPersona.ts`) + - 实现品牌人设的 CRUD 操作 + - 支持模板应用功能 + +- [x] 1.8 新增 BrandPersonaDialog 组件 (`BrandPersonaDialog.tsx`) + - 分步骤创建品牌人设(品牌调性 → 配色方案 → 字体设置 → 预览确认) + - 支持模板快速应用 + - 支持预设配色方案选择 + - 实时预览效果 + +#### 验证标准 +- [x] 能够创建包含配色方案的品牌人设 +- [x] 品牌人设能够正确保存和加载 +- [x] 在项目详情页能够管理品牌人设 + +--- + +### Phase 2: 素材库扩展 +**Goal**: 扩展素材库支持 icon, color, layout 类型 +**Status**: Not Started + +--- + +### Phase 3: 海报 Agent 系统 +**Goal**: 实现 6 个专用 Agent,支持对话式海报设计 +**Status**: Not Started + +--- + +### Phase 4: 工作流系统 +**Goal**: 实现 6 步引导工作流 +**Status**: Not Started + +--- + +### Phase 5: 多平台导出 +**Goal**: 实现多平台尺寸适配和导出 +**Status**: Not Started + +--- + +## 新增文件清单 + +### 后端 (Rust) +- `src-tauri/src/database/dao/brand_persona_dao.rs` - 品牌人设 DAO + +### 前端 (TypeScript/React) +- `src/types/brand-persona.ts` - 品牌人设类型定义 +- `src/hooks/useBrandPersona.ts` - 品牌人设 Hook +- `src/components/projects/dialogs/BrandPersonaDialog.tsx` - 品牌人设对话框 + +## 修改文件清单 + +### 后端 (Rust) +- `src-tauri/src/models/project_model.rs` - 新增品牌人设数据模型 +- `src-tauri/src/database/schema.rs` - 新增品牌人设扩展表 +- `src-tauri/src/database/dao/mod.rs` - 导出新 DAO +- `src-tauri/src/services/persona_service.rs` - 新增品牌人设服务方法 +- `src-tauri/src/commands/persona_cmd.rs` - 新增品牌人设命令 +- `src-tauri/src/app/runner.rs` - 注册新命令 + +### 前端 (TypeScript/React) +- `src/types/index.ts` - 导出新类型 +- `src/hooks/index.ts` - 导出新 Hook +- `src/components/projects/dialogs/index.ts` - 导出新组件 diff --git a/docs/LLM_FLOW_MONITOR_SPEC.md b/docs/LLM_FLOW_MONITOR_SPEC.md deleted file mode 100644 index 2c31eba92..000000000 --- a/docs/LLM_FLOW_MONITOR_SPEC.md +++ /dev/null @@ -1,1565 +0,0 @@ -# LLM Flow Monitor - 详细设计方案 - -> 参考 mitmproxy 的 Flow 模型,为 ProxyCast 设计一套完整的 LLM API 流量监控系统, -> 用于捕获、存储、分析和回放 AI Agent 与大模型之间的完整交互数据。 - -## 一、背景与目标 - -### 1.1 当前问题 - -1. **日志信息不完整**:当前 `RequestLog` 只记录元数据(id、provider、model、duration、tokens),不保存完整的请求和响应内容 -2. **流式响应丢失**:SSE 流式响应的 chunks 分散,无法重建完整的响应内容 -3. **无法调试 Agent**:开发 AI Agent 时,需要查看完整的 prompt 和 response 来调优 -4. **缺乏历史回放**:无法回放历史请求,难以复现问题 -5. **数据不可导出**:无法导出为标准格式(如 HAR)供其他工具分析 - -### 1.2 设计目标 - -1. **完整捕获**:记录每个请求的完整 headers、body、响应内容 -2. **流式重建**:自动将 SSE chunks 合并为完整响应 -3. **高效存储**:内存 + 文件双层存储,支持大量请求 -4. **灵活查询**:按时间、模型、provider、内容等多维度过滤 -5. **标准导出**:支持 HAR、JSON、Markdown 等格式导出 -6. **实时监控**:前端实时展示请求列表和详情 -7. **隐私保护**:敏感信息脱敏,可配置存储策略 - ---- - -## 二、数据模型设计 - -### 2.1 核心数据结构 - -```rust -/// LLM 请求/响应流 -/// 类似 mitmproxy 的 HTTPFlow,但专门针对 LLM API 优化 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LLMFlow { - /// 唯一标识符 - pub id: String, - - /// 流类型 - pub flow_type: FlowType, - - /// 请求信息 - pub request: LLMRequest, - - /// 响应信息(可能为空,如请求失败) - pub response: Option, - - /// 错误信息(如果发生错误) - pub error: Option, - - /// 元数据 - pub metadata: FlowMetadata, - - /// 时间戳 - pub timestamps: FlowTimestamps, - - /// 流状态 - pub state: FlowState, - - /// 用户标记和注释 - pub annotations: FlowAnnotations, -} - -/// 流类型 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FlowType { - /// OpenAI Chat Completions - ChatCompletions, - /// Anthropic Messages - AnthropicMessages, - /// Gemini Generate Content - GeminiGenerateContent, - /// Embeddings - Embeddings, - /// 其他 - Other(String), -} - -/// 流状态 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FlowState { - /// 等待响应 - Pending, - /// 正在流式传输 - Streaming, - /// 已完成 - Completed, - /// 失败 - Failed, - /// 已取消 - Cancelled, - /// 已拦截(用于调试) - Intercepted, -} -``` - -### 2.2 请求数据结构 - -```rust -/// LLM 请求 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LLMRequest { - /// HTTP 方法 - pub method: String, - - /// 请求路径 - pub path: String, - - /// 请求头 - pub headers: HashMap, - - /// 原始请求体(JSON) - pub body: serde_json::Value, - - /// 解析后的消息列表 - pub messages: Vec, - - /// 系统提示词(如果有) - pub system_prompt: Option, - - /// 工具定义(如果有) - pub tools: Option>, - - /// 请求的模型名称 - pub model: String, - - /// 原始模型名称(别名解析前) - pub original_model: Option, - - /// 请求参数 - pub parameters: RequestParameters, - - /// 请求体大小(字节) - pub size_bytes: usize, - - /// 请求开始时间戳 - pub timestamp: DateTime, -} - -/// 消息结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - /// 角色 - pub role: MessageRole, - - /// 内容(可以是文本或多模态) - pub content: MessageContent, - - /// 工具调用(assistant 消息) - pub tool_calls: Option>, - - /// 工具结果(tool 消息) - pub tool_result: Option, - - /// 消息名称(function/tool 消息) - pub name: Option, -} - -/// 消息角色 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MessageRole { - System, - User, - Assistant, - Tool, - Function, -} - -/// 消息内容(支持多模态) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum MessageContent { - /// 纯文本 - Text(String), - - /// 多模态内容 - MultiModal(Vec), -} - -/// 内容部分 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ContentPart { - /// 文本 - #[serde(rename = "text")] - Text { text: String }, - - /// 图片 - #[serde(rename = "image_url")] - Image { - image_url: ImageUrl, - /// 图片摘要(用于显示,不存储完整 base64) - #[serde(skip_serializing_if = "Option::is_none")] - thumbnail: Option, - }, - - /// 音频 - #[serde(rename = "audio")] - Audio { - audio: AudioData, - }, - - /// 文件 - #[serde(rename = "file")] - File { - file: FileData, - }, -} - -/// 请求参数 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RequestParameters { - /// 温度 - pub temperature: Option, - /// Top P - pub top_p: Option, - /// 最大 tokens - pub max_tokens: Option, - /// 停止序列 - pub stop: Option>, - /// 是否流式 - pub stream: bool, - /// 其他参数 - #[serde(flatten)] - pub extra: HashMap, -} -``` - -### 2.3 响应数据结构 - -```rust -/// LLM 响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LLMResponse { - /// HTTP 状态码 - pub status_code: u16, - - /// 状态文本 - pub status_text: String, - - /// 响应头 - pub headers: HashMap, - - /// 原始响应体(完整 JSON,流式响应会被重建) - pub body: serde_json::Value, - - /// 提取的文本内容 - pub content: String, - - /// 思维链内容(如果有) - pub thinking: Option, - - /// 工具调用(如果有) - pub tool_calls: Vec, - - /// Token 使用统计 - pub usage: TokenUsage, - - /// 停止原因 - pub stop_reason: Option, - - /// 响应体大小(字节) - pub size_bytes: usize, - - /// 响应开始时间戳 - pub timestamp_start: DateTime, - - /// 响应结束时间戳 - pub timestamp_end: DateTime, - - /// 流式响应信息(如果是流式) - pub stream_info: Option, -} - -/// 思维链内容 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThinkingContent { - /// 思维链文本 - pub text: String, - /// 思维链 token 数 - pub tokens: Option, - /// 思维链签名(用于验证) - pub signature: Option, -} - -/// 工具调用 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - /// 调用 ID - pub id: String, - /// 工具类型 - pub call_type: String, - /// 函数信息 - pub function: FunctionCall, -} - -/// 函数调用 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionCall { - /// 函数名 - pub name: String, - /// 参数(JSON 字符串) - pub arguments: String, - /// 解析后的参数(方便查看) - pub parsed_arguments: Option, -} - -/// Token 使用统计 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TokenUsage { - /// 输入 tokens - pub input_tokens: u32, - /// 输出 tokens - pub output_tokens: u32, - /// 缓存读取 tokens - pub cache_read_tokens: Option, - /// 缓存写入 tokens - pub cache_write_tokens: Option, - /// 思维链 tokens - pub thinking_tokens: Option, - /// 总 tokens - pub total_tokens: u32, -} - -/// 停止原因 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum StopReason { - /// 正常结束 - Stop, - /// 达到长度限制 - Length, - /// 工具调用 - ToolUse, - /// 内容过滤 - ContentFilter, - /// 其他 - Other(String), -} - -/// 流式响应信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StreamInfo { - /// 总 chunk 数 - pub chunk_count: u32, - /// 第一个 chunk 延迟(毫秒) - pub first_chunk_latency_ms: u64, - /// 平均 chunk 间隔(毫秒) - pub avg_chunk_interval_ms: f64, - /// 原始 chunks(可选保存) - #[serde(skip_serializing_if = "Option::is_none")] - pub raw_chunks: Option>, -} - -/// 流式 chunk -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StreamChunk { - /// 序号 - pub index: u32, - /// 时间戳 - pub timestamp: DateTime, - /// 原始数据 - pub data: String, - /// 增量内容 - pub delta_content: Option, -} -``` - -### 2.4 元数据结构 - -```rust -/// 流元数据 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowMetadata { - /// Provider 类型 - pub provider: ProviderType, - - /// 使用的凭证 ID - pub credential_id: Option, - - /// 凭证名称(用于显示) - pub credential_name: Option, - - /// 重试次数 - pub retry_count: u32, - - /// 客户端信息 - pub client_info: ClientInfo, - - /// 路由信息 - pub routing_info: RoutingInfo, - - /// 注入的参数 - pub injected_params: Option>, - - /// 上下文使用率(%) - pub context_usage_percentage: Option, -} - -/// 客户端信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientInfo { - /// 客户端 IP - pub ip: Option, - /// User-Agent - pub user_agent: Option, - /// 客户端 SDK - pub sdk: Option, - /// 客户端版本 - pub sdk_version: Option, -} - -/// 路由信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RoutingInfo { - /// 原始模型(别名) - pub original_model: String, - /// 解析后的模型 - pub resolved_model: String, - /// 路由到的 Provider - pub routed_provider: ProviderType, - /// 匹配的路由规则 - pub matched_rule: Option, -} - -/// 时间戳集合 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowTimestamps { - /// 请求创建时间 - pub created: DateTime, - /// 请求发送时间 - pub request_start: DateTime, - /// 请求发送完成时间 - pub request_end: Option>, - /// 响应开始时间(收到第一个字节) - pub response_start: Option>, - /// 响应结束时间 - pub response_end: Option>, - /// 总耗时(毫秒) - pub duration_ms: u64, - /// TTFB(Time To First Byte,毫秒) - pub ttfb_ms: Option, -} - -/// 用户标注 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct FlowAnnotations { - /// 用户标记(如 ⭐、🔴、🟢) - pub marker: Option, - /// 用户备注 - pub comment: Option, - /// 标签 - pub tags: Vec, - /// 是否已收藏 - pub starred: bool, -} -``` - -### 2.5 错误结构 - -```rust -/// 流错误 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowError { - /// 错误类型 - pub error_type: FlowErrorType, - /// 错误消息 - pub message: String, - /// HTTP 状态码(如果有) - pub status_code: Option, - /// 原始错误响应 - pub raw_response: Option, - /// 错误发生时间 - pub timestamp: DateTime, - /// 是否可重试 - pub retryable: bool, -} - -/// 错误类型 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FlowErrorType { - /// 网络错误 - Network, - /// 超时 - Timeout, - /// 认证失败 - Authentication, - /// 限流 - RateLimit, - /// 内容过滤 - ContentFilter, - /// 服务端错误 - ServerError, - /// 请求格式错误 - BadRequest, - /// 模型不可用 - ModelUnavailable, - /// Token 超限 - TokenLimitExceeded, - /// 其他 - Other, -} -``` - ---- - -## 三、流式响应重建 - -### 3.1 SSE 解析器 - -```rust -/// SSE 流重建器 -pub struct StreamRebuilder { - /// 累积的 chunks - chunks: Vec, - /// 累积的内容 - content_buffer: String, - /// 累积的 tool calls - tool_calls_buffer: HashMap, - /// 累积的 thinking - thinking_buffer: Option, - /// 第一个 chunk 时间 - first_chunk_time: Option>, - /// 上一个 chunk 时间 - last_chunk_time: Option>, - /// 流格式 - format: StreamFormat, -} - -/// 流格式 -pub enum StreamFormat { - /// OpenAI 格式 - OpenAI, - /// Anthropic 格式 - Anthropic, - /// Gemini 格式 - Gemini, - /// 未知格式 - Unknown, -} - -impl StreamRebuilder { - /// 处理一个 SSE 事件 - pub fn process_event(&mut self, event: &str, data: &str) -> Result<(), Error> { - let chunk = StreamChunk { - index: self.chunks.len() as u32, - timestamp: Utc::now(), - data: data.to_string(), - delta_content: None, - }; - - // 根据格式解析增量内容 - match self.format { - StreamFormat::OpenAI => self.process_openai_chunk(data, &mut chunk)?, - StreamFormat::Anthropic => self.process_anthropic_chunk(event, data, &mut chunk)?, - StreamFormat::Gemini => self.process_gemini_chunk(data, &mut chunk)?, - _ => {}, - } - - self.chunks.push(chunk); - Ok(()) - } - - /// 完成重建,返回完整响应 - pub fn finish(self) -> LLMResponse { - // 构建完整的响应对象 - LLMResponse { - content: self.content_buffer, - tool_calls: self.tool_calls_buffer.into_values().map(|b| b.build()).collect(), - thinking: self.thinking_buffer.map(|t| ThinkingContent { text: t, tokens: None, signature: None }), - stream_info: Some(StreamInfo { - chunk_count: self.chunks.len() as u32, - first_chunk_latency_ms: self.calculate_first_chunk_latency(), - avg_chunk_interval_ms: self.calculate_avg_interval(), - raw_chunks: if self.should_save_raw_chunks() { Some(self.chunks) } else { None }, - }), - // ... 其他字段 - } - } -} -``` - -### 3.2 不同格式处理 - -```rust -impl StreamRebuilder { - /// 处理 OpenAI 格式的 chunk - fn process_openai_chunk(&mut self, data: &str, chunk: &mut StreamChunk) -> Result<(), Error> { - if data == "[DONE]" { - return Ok(()); - } - - let parsed: OpenAIStreamChunk = serde_json::from_str(data)?; - - for choice in &parsed.choices { - if let Some(delta) = &choice.delta { - // 文本内容 - if let Some(content) = &delta.content { - self.content_buffer.push_str(content); - chunk.delta_content = Some(content.clone()); - } - - // 工具调用 - if let Some(tool_calls) = &delta.tool_calls { - for tc in tool_calls { - self.process_tool_call_delta(tc); - } - } - } - } - - Ok(()) - } - - /// 处理 Anthropic 格式的 chunk - fn process_anthropic_chunk(&mut self, event: &str, data: &str, chunk: &mut StreamChunk) -> Result<(), Error> { - match event { - "content_block_delta" => { - let parsed: AnthropicDelta = serde_json::from_str(data)?; - match &parsed.delta { - Delta::TextDelta { text } => { - self.content_buffer.push_str(text); - chunk.delta_content = Some(text.clone()); - }, - Delta::ThinkingDelta { thinking } => { - self.thinking_buffer.get_or_insert(String::new()).push_str(thinking); - }, - Delta::InputJsonDelta { partial_json } => { - // 处理工具调用参数 - self.process_tool_call_json_delta(parsed.index, partial_json); - }, - } - }, - "content_block_start" => { - // 处理新的内容块 - }, - "message_delta" => { - // 处理消息级别的更新(stop_reason, usage 等) - }, - _ => {}, - } - - Ok(()) - } -} -``` - ---- - -## 四、存储系统设计 - -### 4.1 双层存储架构 - -``` -┌─────────────────────────────────────────────────────┐ -│ 查询层 │ -│ (按 ID / 时间 / 模型 / Provider / 内容 查询) │ -└─────────────────────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ 内存缓存 │ │ 索引层 │ │ 文件层 │ -│ (热数据) │ │ (SQLite) │ │ (JSONL) │ -│ 最近 1000 │ │ 元数据索引 │ │ 完整数据 │ -└─────────────┘ └─────────────┘ └─────────────┘ -``` - -### 4.2 内存缓存 - -```rust -/// 内存 Flow 存储 -pub struct FlowMemoryStore { - /// 按 ID 索引的 flows - flows: HashMap>>, - /// 按时间排序的 flow IDs - ordered_ids: VecDeque, - /// 最大缓存数量 - max_size: usize, - /// 内存使用估算 - memory_usage: AtomicUsize, -} - -impl FlowMemoryStore { - /// 添加 flow - pub fn add(&mut self, flow: LLMFlow) { - let id = flow.id.clone(); - let size = self.estimate_size(&flow); - - self.flows.insert(id.clone(), Arc::new(RwLock::new(flow))); - self.ordered_ids.push_back(id); - self.memory_usage.fetch_add(size, Ordering::Relaxed); - - // 驱逐旧数据 - while self.ordered_ids.len() > self.max_size { - if let Some(old_id) = self.ordered_ids.pop_front() { - if let Some(old_flow) = self.flows.remove(&old_id) { - let old_size = self.estimate_size(&old_flow.read()); - self.memory_usage.fetch_sub(old_size, Ordering::Relaxed); - } - } - } - } - - /// 获取最近 N 条 - pub fn get_recent(&self, limit: usize) -> Vec>> { - self.ordered_ids - .iter() - .rev() - .take(limit) - .filter_map(|id| self.flows.get(id).cloned()) - .collect() - } -} -``` - -### 4.3 文件持久化 - -```rust -/// Flow 文件存储 -pub struct FlowFileStore { - /// 存储目录 - base_dir: PathBuf, - /// 当前写入文件 - current_file: RwLock>, - /// 轮转配置 - rotation_config: RotationConfig, -} - -/// 轮转配置 -pub struct RotationConfig { - /// 按日期轮转 - pub rotate_daily: bool, - /// 单文件最大大小 - pub max_file_size: u64, - /// 保留天数 - pub retention_days: u32, - /// 是否压缩旧文件 - pub compress_old: bool, -} - -impl FlowFileStore { - /// 存储文件结构: - /// ~/.proxycast/flows/ - /// ├── 2024-01-15/ - /// │ ├── flows_001.jsonl - /// │ ├── flows_002.jsonl - /// │ └── index.sqlite (当日索引) - /// ├── 2024-01-14/ - /// │ ├── flows.jsonl.gz (压缩后) - /// │ └── index.sqlite - /// └── global_index.sqlite (全局索引) - - /// 写入 flow - pub async fn write(&self, flow: &LLMFlow) -> Result<(), Error> { - let mut writer = self.get_or_create_writer().await?; - - // 写入 JSONL - let json = serde_json::to_string(flow)?; - writer.write_line(&json).await?; - - // 更新索引 - self.update_index(flow).await?; - - // 检查是否需要轮转 - if writer.size() > self.rotation_config.max_file_size { - self.rotate().await?; - } - - Ok(()) - } - - /// 按条件查询 - pub async fn query(&self, filter: &FlowFilter) -> Result, Error> { - // 先查询索引获取文件位置 - let locations = self.query_index(filter).await?; - - // 从文件读取 - let mut flows = Vec::new(); - for loc in locations { - let flow = self.read_flow(&loc).await?; - if filter.matches(&flow) { - flows.push(flow); - } - } - - Ok(flows) - } -} -``` - -### 4.4 SQLite 索引 - -```sql --- 全局索引表 -CREATE TABLE flow_index ( - id TEXT PRIMARY KEY, - created_at DATETIME NOT NULL, - provider TEXT NOT NULL, - model TEXT NOT NULL, - status TEXT NOT NULL, - duration_ms INTEGER, - input_tokens INTEGER, - output_tokens INTEGER, - has_error BOOLEAN DEFAULT FALSE, - has_tool_calls BOOLEAN DEFAULT FALSE, - has_thinking BOOLEAN DEFAULT FALSE, - file_path TEXT NOT NULL, - file_offset INTEGER NOT NULL, - -- 用于全文搜索 - content_preview TEXT, - request_preview TEXT -); - -CREATE INDEX idx_created_at ON flow_index(created_at); -CREATE INDEX idx_provider ON flow_index(provider); -CREATE INDEX idx_model ON flow_index(model); -CREATE INDEX idx_status ON flow_index(status); - --- 全文搜索表(可选,使用 FTS5) -CREATE VIRTUAL TABLE flow_fts USING fts5( - id, - content, - request, - thinking, - content='flow_index' -); -``` - ---- - -## 五、查询与过滤 - -### 5.1 过滤器设计 - -```rust -/// Flow 过滤器 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowFilter { - /// 时间范围 - pub time_range: Option, - - /// Provider 过滤 - pub providers: Option>, - - /// 模型过滤(支持通配符) - pub models: Option>, - - /// 状态过滤 - pub states: Option>, - - /// 是否有错误 - pub has_error: Option, - - /// 是否有工具调用 - pub has_tool_calls: Option, - - /// 是否有思维链 - pub has_thinking: Option, - - /// 是否流式 - pub is_streaming: Option, - - /// 内容搜索(全文) - pub content_search: Option, - - /// 请求内容搜索 - pub request_search: Option, - - /// Token 范围 - pub token_range: Option, - - /// 延迟范围 - pub latency_range: Option, - - /// 标签过滤 - pub tags: Option>, - - /// 只显示收藏 - pub starred_only: bool, - - /// 凭证 ID - pub credential_id: Option, -} - -/// 排序选项 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FlowSortBy { - /// 创建时间(默认) - CreatedAt, - /// 耗时 - Duration, - /// Token 数 - TotalTokens, - /// 内容长度 - ContentLength, - /// 模型 - Model, -} -``` - -### 5.2 查询 API - -```rust -/// Flow 查询服务 -pub struct FlowQueryService { - memory_store: Arc, - file_store: Arc, -} - -impl FlowQueryService { - /// 查询 flows - pub async fn query(&self, - filter: FlowFilter, - sort_by: FlowSortBy, - sort_desc: bool, - page: usize, - page_size: usize, - ) -> Result { - // 优先从内存查询 - let mut flows = self.memory_store.query(&filter); - - // 如果需要更多数据,从文件查询 - if flows.len() < page * page_size { - let file_flows = self.file_store.query(&filter).await?; - flows.extend(file_flows); - } - - // 排序 - self.sort_flows(&mut flows, sort_by, sort_desc); - - // 分页 - let total = flows.len(); - let start = page * page_size; - let end = (start + page_size).min(total); - let flows = flows[start..end].to_vec(); - - Ok(FlowQueryResult { - flows, - total, - page, - page_size, - }) - } - - /// 获取统计信息 - pub async fn get_stats(&self, filter: &FlowFilter) -> FlowStats { - // 计算聚合统计 - } - - /// 全文搜索 - pub async fn search(&self, query: &str, limit: usize) -> Vec { - // 使用 FTS 搜索 - } -} -``` - ---- - -## 六、导出功能 - -### 6.1 支持的导出格式 - -```rust -/// 导出格式 -pub enum ExportFormat { - /// HAR (HTTP Archive) 格式 - HAR, - /// JSON 格式 - JSON, - /// JSONL (每行一个 JSON) - JSONL, - /// Markdown 格式(用于文档) - Markdown, - /// CSV 格式(仅元数据) - CSV, - /// OpenAI JSONL(用于 fine-tuning) - OpenAIFineTune, - /// Anthropic JSONL(用于 fine-tuning) - AnthropicFineTune, -} - -/// 导出选项 -pub struct ExportOptions { - /// 导出格式 - pub format: ExportFormat, - /// 过滤器 - pub filter: FlowFilter, - /// 是否包含原始数据 - pub include_raw: bool, - /// 是否包含流式 chunks - pub include_stream_chunks: bool, - /// 是否脱敏 - pub redact_sensitive: bool, - /// 脱敏规则 - pub redaction_rules: Vec, - /// 是否压缩 - pub compress: bool, -} -``` - -### 6.2 HAR 导出 - -```rust -impl FlowExporter { - /// 导出为 HAR 格式 - pub fn export_har(&self, flows: &[LLMFlow]) -> HarArchive { - HarArchive { - log: HarLog { - version: "1.2".to_string(), - creator: HarCreator { - name: "ProxyCast".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - entries: flows.iter().map(|f| self.flow_to_har_entry(f)).collect(), - // LLM 特定扩展 - _llm_metadata: Some(LLMHarMetadata { - total_tokens: flows.iter().map(|f| f.response.as_ref().map(|r| r.usage.total_tokens).unwrap_or(0) as u64).sum(), - models_used: flows.iter().map(|f| f.request.model.clone()).collect::>().into_iter().collect(), - providers_used: flows.iter().map(|f| f.metadata.provider.to_string()).collect::>().into_iter().collect(), - }), - }, - } - } - - fn flow_to_har_entry(&self, flow: &LLMFlow) -> HarEntry { - HarEntry { - started_date_time: flow.timestamps.created.to_rfc3339(), - time: flow.timestamps.duration_ms as f64, - request: HarRequest { - method: flow.request.method.clone(), - url: format!("https://api.provider.com{}", flow.request.path), - http_version: "HTTP/1.1".to_string(), - headers: flow.request.headers.iter() - .map(|(k, v)| HarHeader { name: k.clone(), value: v.clone() }) - .collect(), - post_data: Some(HarPostData { - mime_type: "application/json".to_string(), - text: serde_json::to_string(&flow.request.body).unwrap(), - }), - // ... - }, - response: flow.response.as_ref().map(|r| HarResponse { - status: r.status_code as i32, - status_text: r.status_text.clone(), - headers: r.headers.iter() - .map(|(k, v)| HarHeader { name: k.clone(), value: v.clone() }) - .collect(), - content: HarContent { - size: r.size_bytes as i64, - mime_type: "application/json".to_string(), - text: Some(serde_json::to_string(&r.body).unwrap()), - }, - // ... - }), - // LLM 特定扩展 - _llm: Some(LLMHarExtension { - provider: flow.metadata.provider.to_string(), - model: flow.request.model.clone(), - input_tokens: flow.response.as_ref().map(|r| r.usage.input_tokens), - output_tokens: flow.response.as_ref().map(|r| r.usage.output_tokens), - has_tool_calls: flow.response.as_ref().map(|r| !r.tool_calls.is_empty()).unwrap_or(false), - has_thinking: flow.response.as_ref().and_then(|r| r.thinking.as_ref()).is_some(), - }), - } - } -} -``` - -### 6.3 Markdown 导出(用于文档和分享) - -```rust -impl FlowExporter { - /// 导出为 Markdown(用于复制分享) - pub fn export_markdown(&self, flow: &LLMFlow) -> String { - let mut md = String::new(); - - // 标题 - writeln!(md, "# LLM Request - {}", flow.id).unwrap(); - writeln!(md, "").unwrap(); - - // 元信息 - writeln!(md, "## Metadata").unwrap(); - writeln!(md, "- **Provider**: {}", flow.metadata.provider).unwrap(); - writeln!(md, "- **Model**: {}", flow.request.model).unwrap(); - writeln!(md, "- **Time**: {}", flow.timestamps.created).unwrap(); - writeln!(md, "- **Duration**: {}ms", flow.timestamps.duration_ms).unwrap(); - writeln!(md, "").unwrap(); - - // 请求 - writeln!(md, "## Request").unwrap(); - if let Some(system) = &flow.request.system_prompt { - writeln!(md, "### System Prompt").unwrap(); - writeln!(md, "```").unwrap(); - writeln!(md, "{}", system).unwrap(); - writeln!(md, "```").unwrap(); - } - - writeln!(md, "### Messages").unwrap(); - for msg in &flow.request.messages { - writeln!(md, "**{}**:", msg.role).unwrap(); - writeln!(md, "{}", msg.content.to_string()).unwrap(); - writeln!(md, "").unwrap(); - } - - // 响应 - if let Some(resp) = &flow.response { - writeln!(md, "## Response").unwrap(); - - if let Some(thinking) = &resp.thinking { - writeln!(md, "### Thinking").unwrap(); - writeln!(md, "
Click to expand").unwrap(); - writeln!(md, "").unwrap(); - writeln!(md, "{}", thinking.text).unwrap(); - writeln!(md, "
").unwrap(); - writeln!(md, "").unwrap(); - } - - writeln!(md, "### Content").unwrap(); - writeln!(md, "{}", resp.content).unwrap(); - - if !resp.tool_calls.is_empty() { - writeln!(md, "### Tool Calls").unwrap(); - for tc in &resp.tool_calls { - writeln!(md, "- **{}**: `{}`", tc.function.name, tc.function.arguments).unwrap(); - } - } - - writeln!(md, "### Usage").unwrap(); - writeln!(md, "- Input: {} tokens", resp.usage.input_tokens).unwrap(); - writeln!(md, "- Output: {} tokens", resp.usage.output_tokens).unwrap(); - } - - md - } -} -``` - ---- - -## 七、前端界面设计 - -### 7.1 流量列表视图 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 🔍 Search... │ Provider ▾ │ Model ▾ │ Status ▾ │ Time Range ▾ │ ⚙️ Export │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────────┐ │ -│ │ ⭐ 14:32:05 │ claude-sonnet-4-5 │ Kiro │ ✅ 2.3s │ 1.2k→3.4k │ 🔧 tool │ │ -│ │ "请帮我分析这段代码的性能问题..." │ │ -│ └─────────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────────┐ │ -│ │ 14:31:42 │ gemini-2.5-flash │ Gemini │ ✅ 0.8s │ 500→1.2k │ │ │ -│ │ "Write a Python function to..." │ │ -│ └─────────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────────┐ │ -│ │ 14:31:15 │ claude-sonnet-4-5 │ Kiro │ ❌ 5.2s │ Error: Rate limit │ │ -│ │ "Explain the difference between..." │ │ -│ └─────────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 7.2 流量详情视图 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ ← Back │ Request abc123 │ ⭐ Star │ 📋 Copy │ 📤 Export │ 🔄 Replay │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─ Metadata ────────────────────────────────────────────────────────────┐ │ -│ │ Provider: Kiro Model: claude-sonnet-4-5 │ │ -│ │ Duration: 2.3s TTFB: 1.2s │ │ -│ │ Tokens: 1,234 → 3,456 Cost: $0.045 │ │ -│ │ Credential: work-account-1 │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Request ─────────────────────────────────────────────────────────────┐ │ -│ │ [Headers] [Body] [Messages] [Tools] │ │ -│ │ │ │ -│ │ System: You are a helpful assistant... │ │ -│ │ │ │ -│ │ User: 请帮我分析这段代码的性能问题: │ │ -│ │ ```python │ │ -│ │ def slow_function(): │ │ -│ │ for i in range(10000): │ │ -│ │ result = expensive_operation(i) │ │ -│ │ ``` │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Response ────────────────────────────────────────────────────────────┐ │ -│ │ [Content] [Thinking] [Tool Calls] [Raw] [Stream] │ │ -│ │ │ │ -│ │ 这段代码存在几个性能问题: │ │ -│ │ │ │ -│ │ 1. **循环中的重复计算**:`expensive_operation` 被调用 10000 次... │ │ -│ │ 2. **缺少缓存**:如果操作结果可以重用... │ │ -│ │ │ │ -│ │ [Show more...] │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Timeline ────────────────────────────────────────────────────────────┐ │ -│ │ Request ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0.1s │ │ -│ │ TTFB ░░░░████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1.2s │ │ -│ │ Stream ░░░░░░░░░░░░░░░░░░░░░░░░██████████████████████████░ 1.0s │ │ -│ │ Total ████████████████████████████████████████████████████ 2.3s │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 7.3 统计仪表板 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 📊 Flow Statistics │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─ Overview ──────────────────────────┐ ┌─ Token Usage ─────────────────┐ │ -│ │ Total Requests │ 1,234 │ │ │ │ -│ │ Success Rate │ 98.2% │ │ ███████████ Input: 1.2M │ │ -│ │ Avg Latency │ 1.8s │ │ █████████████████ Output: 2.1M│ │ -│ │ Total Tokens │ 3.3M │ │ │ │ -│ └─────────────────────────────────────┘ └───────────────────────────────┘ │ -│ │ -│ ┌─ Requests by Provider ──────────────────────────────────────────────────┐│ -│ │ Kiro ██████████████████████████████████████████░░░░░░░░ 68% ││ -│ │ Gemini ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 22% ││ -│ │ OpenAI ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 10% ││ -│ └─────────────────────────────────────────────────────────────────────────┘│ -│ │ -│ ┌─ Latency Distribution ─────────────┐ ┌─ Requests Timeline ───────────┐ │ -│ │ ▃▅█▇▅▃▂▁ │ │ ▂▃▅▇█▇▅▃▂▁▂▃▅▇█▇▅▃▂ │ │ -│ │ 0s 1s 2s 3s 4s 5s+ │ │ 00:00 06:00 12:00 18:00│ │ -│ └────────────────────────────────────┘ └────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 八、API 设计 - -### 8.1 Tauri Commands - -```rust -// 查询 flows -#[tauri::command] -async fn query_flows( - filter: FlowFilter, - sort_by: Option, - sort_desc: Option, - page: Option, - page_size: Option, - state: State<'_, FlowMonitorState>, -) -> Result; - -// 获取单个 flow 详情 -#[tauri::command] -async fn get_flow_detail( - id: String, - state: State<'_, FlowMonitorState>, -) -> Result; - -// 搜索 flows -#[tauri::command] -async fn search_flows( - query: String, - limit: Option, - state: State<'_, FlowMonitorState>, -) -> Result, String>; - -// 获取统计信息 -#[tauri::command] -async fn get_flow_stats( - filter: Option, - state: State<'_, FlowMonitorState>, -) -> Result; - -// 导出 flows -#[tauri::command] -async fn export_flows( - options: ExportOptions, - path: String, - state: State<'_, FlowMonitorState>, -) -> Result; - -// 更新 flow 标注 -#[tauri::command] -async fn update_flow_annotations( - id: String, - annotations: FlowAnnotations, - state: State<'_, FlowMonitorState>, -) -> Result<(), String>; - -// 重放请求 -#[tauri::command] -async fn replay_flow( - id: String, - modifications: Option, - state: State<'_, FlowMonitorState>, -) -> Result; - -// 清理旧数据 -#[tauri::command] -async fn cleanup_flows( - before: DateTime, - state: State<'_, FlowMonitorState>, -) -> Result; -``` - -### 8.2 WebSocket 实时推送 - -```rust -/// 实时 Flow 事件 -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type")] -pub enum FlowEvent { - /// 新 flow 开始 - FlowStarted { flow: FlowSummary }, - /// flow 更新(收到响应数据) - FlowUpdated { id: String, update: FlowUpdate }, - /// flow 完成 - FlowCompleted { id: String, summary: FlowSummary }, - /// flow 失败 - FlowFailed { id: String, error: FlowError }, - /// 统计更新 - StatsUpdated { stats: FlowStats }, -} - -/// Flow 摘要(用于列表显示) -#[derive(Debug, Clone, Serialize)] -pub struct FlowSummary { - pub id: String, - pub provider: String, - pub model: String, - pub state: FlowState, - pub duration_ms: Option, - pub input_tokens: Option, - pub output_tokens: Option, - pub content_preview: String, - pub has_error: bool, - pub has_tool_calls: bool, - pub created_at: DateTime, -} -``` - ---- - -## 九、性能与隐私 - -### 9.1 性能优化 - -```rust -/// Flow 监控配置 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowMonitorConfig { - /// 是否启用监控 - pub enabled: bool, - - /// 内存中最大 flow 数量 - pub max_memory_flows: usize, - - /// 是否保存到文件 - pub persist_to_file: bool, - - /// 文件保留天数 - pub retention_days: u32, - - /// 是否保存原始 stream chunks - pub save_stream_chunks: bool, - - /// 请求体大小限制(超过则截断) - pub max_request_body_size: usize, - - /// 响应体大小限制 - pub max_response_body_size: usize, - - /// 是否保存图片内容(base64) - pub save_image_content: bool, - - /// 图片缩略图大小 - pub thumbnail_size: (u32, u32), - - /// 采样率(0.0-1.0,用于高流量场景) - pub sampling_rate: f32, - - /// 排除的模型(不记录) - pub excluded_models: Vec, - - /// 排除的路径 - pub excluded_paths: Vec, -} - -impl Default for FlowMonitorConfig { - fn default() -> Self { - Self { - enabled: true, - max_memory_flows: 1000, - persist_to_file: true, - retention_days: 7, - save_stream_chunks: false, // 默认不保存原始 chunks - max_request_body_size: 1024 * 1024, // 1MB - max_response_body_size: 10 * 1024 * 1024, // 10MB - save_image_content: false, // 默认不保存图片 - thumbnail_size: (100, 100), - sampling_rate: 1.0, - excluded_models: vec![], - excluded_paths: vec!["/health".to_string()], - } - } -} -``` - -### 9.2 隐私保护 - -```rust -/// 脱敏规则 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedactionRule { - /// 规则名称 - pub name: String, - /// 匹配模式(正则) - pub pattern: String, - /// 替换内容 - pub replacement: String, - /// 应用位置 - pub apply_to: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum RedactionTarget { - /// 请求头 - RequestHeaders, - /// 请求体 - RequestBody, - /// 响应头 - ResponseHeaders, - /// 响应体 - ResponseBody, - /// 所有位置 - All, -} - -impl Default for Vec { - fn default() -> Self { - vec![ - // API Key 脱敏 - RedactionRule { - name: "api_key".to_string(), - pattern: r"(sk-[a-zA-Z0-9]{20,}|api[_-]?key[=:]\s*['\"]?)[a-zA-Z0-9\-_]+".to_string(), - replacement: "$1***REDACTED***".to_string(), - apply_to: vec![RedactionTarget::All], - }, - // Email 脱敏 - RedactionRule { - name: "email".to_string(), - pattern: r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}".to_string(), - replacement: "***@***.***".to_string(), - apply_to: vec![RedactionTarget::RequestBody, RedactionTarget::ResponseBody], - }, - // 手机号脱敏 - RedactionRule { - name: "phone".to_string(), - pattern: r"\b1[3-9]\d{9}\b".to_string(), - replacement: "1**********".to_string(), - apply_to: vec![RedactionTarget::RequestBody, RedactionTarget::ResponseBody], - }, - ] - } -} -``` - ---- - -## 十、实现路线图 - -### Phase 1: 基础设施(1-2 周) - -- [ ] 定义完整的数据模型(LLMFlow, LLMRequest, LLMResponse) -- [ ] 实现内存存储 FlowMemoryStore -- [ ] 实现 SSE 流重建器 StreamRebuilder -- [ ] 在现有 API handlers 中集成 flow 捕获 - -### Phase 2: 持久化与查询(1-2 周) - -- [ ] 实现文件存储 FlowFileStore -- [ ] 实现 SQLite 索引 -- [ ] 实现查询过滤器 -- [ ] 添加全文搜索支持 - -### Phase 3: 前端界面(2-3 周) - -- [ ] 实现 Flow 列表页面 -- [ ] 实现 Flow 详情页面 -- [ ] 实现统计仪表板 -- [ ] 实现实时更新(WebSocket) - -### Phase 4: 导出与高级功能(1-2 周) - -- [ ] 实现 HAR 导出 -- [ ] 实现 Markdown 导出 -- [ ] 实现请求重放 -- [ ] 实现隐私脱敏 - -### Phase 5: 优化与文档(1 周) - -- [ ] 性能优化 -- [ ] 编写用户文档 -- [ ] 添加测试用例 -- [ ] 发布 v1.0 - ---- - -## 十一、附录 - -### A. 与现有系统的集成点 - -1. **server/handlers/api.rs**: 在 `chat_completions` 和 `anthropic_messages` 函数中添加 flow 捕获 -2. **server_utils.rs**: 复用 `parse_cw_response` 用于流式响应解析 -3. **services/provider_pool_service.rs**: 获取凭证信息用于 metadata -4. **models/log_model.rs**: 将 RequestLog 与 LLMFlow 关联 - -### B. 参考实现 - -- [mitmproxy](https://github.com/mitmproxy/mitmproxy) - HTTP 流量捕获的黄金标准 -- [Charles Proxy](https://www.charlesproxy.com/) - 商业代理调试工具 -- [Fiddler](https://www.telerik.com/fiddler) - .NET 平台代理调试工具 -- [LangSmith](https://smith.langchain.com/) - LangChain 官方的 LLM 可观测性平台 - -### C. 数据大小估算 - -| 场景 | 请求数/天 | 平均大小 | 日存储量 | 月存储量 | -|------|----------|---------|---------|---------| -| 个人开发 | 100 | 10KB | 1MB | 30MB | -| 团队开发 | 1,000 | 15KB | 15MB | 450MB | -| 生产环境 | 10,000 | 20KB | 200MB | 6GB | - -### D. 安全考虑 - -1. **本地存储**:所有数据存储在本地,不上传到任何服务器 -2. **访问控制**:通过 API Key 验证访问 -3. **数据加密**:敏感数据可选加密存储 -4. **审计日志**:记录所有导出和访问操作 - ---- - -## 十二、开放问题 - -1. **图片处理策略**:是否保存完整的 base64 图片内容?还是只保存缩略图? -2. **音频处理**:如何处理音频内容? -3. **多租户支持**:是否需要支持多个 workspace 隔离数据? -4. **云同步**:是否需要支持跨设备同步 flow 数据? -5. **对比功能**:是否需要支持两个 flow 的对比功能? -6. **回归测试**:是否需要将保存的 flow 作为回归测试用例? - ---- - -*文档版本:v1.0* -*最后更新:2024-01* -*作者:ProxyCast Team* diff --git a/docs/design/a2ui-persistence.md b/docs/design/a2ui-persistence.md new file mode 100644 index 000000000..46c0321a9 --- /dev/null +++ b/docs/design/a2ui-persistence.md @@ -0,0 +1,186 @@ +# A2UI 表单数据持久化设计 + +## 问题背景 + +当前 A2UI 表单数据只存在于前端内存中,页面刷新或切换话题后会丢失。用户填写的表单数据需要持久化到数据库,以便重新进入时能够恢复。 + +## 数据分析 + +### 需要持久化的数据 + +1. **A2UI 响应结构** (`A2UIResponse`) + - `id`: 响应 ID + - `components`: 组件列表(包含表单字段定义) + - `root`: 根组件 ID + - `data`: 初始数据模型 + - `submitAction`: 提交动作配置 + +2. **用户填写的表单数据** (`A2UIFormData`) + - 键值对形式,key 是组件 ID,value 是用户输入的值 + - 例如:`{ "scene": "我和同事说", "feeling": "对 go 很陌生" }` + +3. **表单状态** + - `submitted`: 是否已提交 + - `submittedAt`: 提交时间 + - `submittedData`: 提交时的数据快照 + +## 设计方案 + +### 方案 A:扩展 agent_messages 表(推荐) + +在现有 `agent_messages` 表中添加字段存储 A2UI 相关数据: + +```sql +-- 添加 A2UI 相关字段 +ALTER TABLE agent_messages ADD COLUMN a2ui_response_json TEXT; +ALTER TABLE agent_messages ADD COLUMN a2ui_form_data_json TEXT; +ALTER TABLE agent_messages ADD COLUMN a2ui_submitted INTEGER DEFAULT 0; +ALTER TABLE agent_messages ADD COLUMN a2ui_submitted_at TEXT; +``` + +**优点**: +- 数据与消息紧密关联,查询简单 +- 不需要额外的表和外键 +- 迁移简单 + +**缺点**: +- 消息表字段增多 +- 如果一条消息有多个 A2UI 表单,需要用 JSON 数组存储 + +### 方案 B:独立的 A2UI 表单表 + +创建独立的表存储 A2UI 表单数据: + +```sql +CREATE TABLE IF NOT EXISTS a2ui_forms ( + id TEXT PRIMARY KEY, + message_id INTEGER NOT NULL, + session_id TEXT NOT NULL, + a2ui_response_json TEXT NOT NULL, + form_data_json TEXT DEFAULT '{}', + submitted INTEGER DEFAULT 0, + submitted_at TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (message_id) REFERENCES agent_messages(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_a2ui_forms_message ON a2ui_forms(message_id); +CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id); +``` + +**优点**: +- 数据结构清晰 +- 支持一条消息多个表单 +- 便于单独查询和管理表单数据 + +**缺点**: +- 需要额外的表和外键 +- 查询时需要 JOIN + +## 推荐方案:方案 B + +考虑到: +1. 一条 AI 消息可能包含多个 A2UI 表单 +2. 表单数据需要独立更新(用户填写时实时保存) +3. 未来可能需要表单历史版本、表单模板等功能 + +### 数据流设计 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 前端 (React) │ +├─────────────────────────────────────────────────────────────────┤ +│ StreamingRenderer │ +│ │ │ +│ ▼ │ +│ A2UIRenderer ──────► onFormChange() ──────► 防抖保存 │ +│ │ │ +│ ▼ │ +│ onSubmit() ──────────────────────────────► 提交表单 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Tauri Commands │ +├─────────────────────────────────────────────────────────────────┤ +│ save_a2ui_form_data(form_id, form_data) │ +│ submit_a2ui_form(form_id, form_data) │ +│ get_a2ui_forms_by_session(session_id) │ +│ get_a2ui_form_by_message(message_id) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Rust Backend │ +├─────────────────────────────────────────────────────────────────┤ +│ A2UIFormService │ +│ - save_form_data() │ +│ - submit_form() │ +│ - get_forms_by_session() │ +│ - get_form_by_message() │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ SQLite Database │ +├─────────────────────────────────────────────────────────────────┤ +│ a2ui_forms 表 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 前端改动 + +1. **A2UIRenderer 组件** + - 添加 `formId` prop(从后端获取或生成) + - 添加 `initialFormData` prop(从后端加载) + - 添加 `onFormChange` 回调(防抖保存) + +2. **useAgentChat Hook** + - `switchTopic` 时加载该会话的所有 A2UI 表单数据 + - 将表单数据与消息关联 + +3. **StreamingRenderer 组件** + - 传递表单数据给 A2UIRenderer + +### 后端改动 + +1. **数据库 Schema** + - 添加 `a2ui_forms` 表 + +2. **Tauri Commands** + - `save_a2ui_form_data`: 保存表单数据(防抖调用) + - `submit_a2ui_form`: 提交表单 + - `get_a2ui_forms_by_session`: 获取会话的所有表单 + - `create_a2ui_form`: 创建新表单记录 + +3. **消息保存逻辑** + - 保存 AI 消息时,解析 A2UI 内容并创建表单记录 + +## 实现步骤 + +### Phase 1: 数据库层 +1. 添加 `a2ui_forms` 表到 schema.rs +2. 创建 A2UIFormDao + +### Phase 2: 后端服务 +1. 创建 A2UIFormService +2. 添加 Tauri Commands + +### Phase 3: 前端集成 +1. 添加 API 调用函数 +2. 修改 A2UIRenderer 支持数据持久化 +3. 修改 useAgentChat 加载表单数据 + +### Phase 4: 测试和优化 +1. 测试表单数据保存和恢复 +2. 优化防抖保存策略 +3. 处理边界情况(网络错误、并发等) + +## 注意事项 + +1. **防抖保存**:用户输入时不要每次都保存,使用 500ms 防抖 +2. **乐观更新**:先更新 UI,后台异步保存 +3. **错误处理**:保存失败时提示用户,但不阻塞操作 +4. **数据清理**:删除会话时级联删除表单数据 diff --git a/package.json b/package.json index 9d7e0f9ea..74d190eee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.56.0", + "version": "0.57.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 89bfcc41c..d470c8f0e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster" -version = "0.7.1" +version = "0.8.0" dependencies = [ "ahash", "anyhow", @@ -2112,7 +2112,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2128,7 +2128,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -3988,7 +3988,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -5307,7 +5307,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.114", @@ -6024,9 +6024,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -6035,7 +6033,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -6139,12 +6139,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6545,7 +6545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -6553,7 +6553,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.56.0" +version = "0.57.0" dependencies = [ "anyhow", "arboard", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.56.0" +version = "0.57.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -6651,7 +6651,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.56.0" +version = "0.57.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7969,7 +7969,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ff34d5ed..811b2815d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.56.0" +version = "0.57.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -164,7 +164,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.56.0" +version = "0.57.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -297,4 +297,6 @@ tempfile.workspace = true [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] +# 本地 Whisper 语音识别(编译很慢,CI 默认不启用) +local-whisper = ["voice-core/local-whisper"] notification = [] # 预留特性:系统通知功能 diff --git a/src-tauri/crates/voice-core/Cargo.toml b/src-tauri/crates/voice-core/Cargo.toml index 450bb293f..6ce8e8c2f 100644 --- a/src-tauri/crates/voice-core/Cargo.toml +++ b/src-tauri/crates/voice-core/Cargo.toml @@ -6,12 +6,17 @@ description = "语音输入核心库 - 音频录制、语音识别、文字输 authors = ["ProxyCast Team"] license = "MIT" +[features] +default = [] +# 本地 Whisper 识别(编译很慢,CI 默认不启用) +local-whisper = ["dep:whisper-rs"] + [dependencies] # 音频录制 cpal = "0.15" -# Whisper 本地识别 -whisper-rs = "0.12" +# Whisper 本地识别(可选,编译耗时) +whisper-rs = { version = "0.12", optional = true } # WAV 处理 hound = "3.5" diff --git a/src-tauri/crates/voice-core/src/lib.rs b/src-tauri/crates/voice-core/src/lib.rs index 9279ce989..f8f68c3a7 100644 --- a/src-tauri/crates/voice-core/src/lib.rs +++ b/src-tauri/crates/voice-core/src/lib.rs @@ -7,11 +7,13 @@ pub mod asr_client; pub mod error; pub mod output; pub mod recorder; +#[cfg(feature = "local-whisper")] pub mod transcriber; pub mod types; pub use error::{Result, VoiceError}; pub use output::OutputHandler; pub use recorder::AudioRecorder; +#[cfg(feature = "local-whisper")] pub use transcriber::WhisperTranscriber; pub use types::*; diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 3d27f4cbd..cd204c811 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1259,6 +1259,14 @@ pub fn run() { commands::persona_cmd::set_default_persona, commands::persona_cmd::list_persona_templates, commands::persona_cmd::get_default_persona, + commands::persona_cmd::generate_persona, + // Brand Persona commands + commands::persona_cmd::get_brand_persona, + commands::persona_cmd::get_brand_extension, + commands::persona_cmd::save_brand_extension, + commands::persona_cmd::update_brand_extension, + commands::persona_cmd::delete_brand_extension, + commands::persona_cmd::list_brand_persona_templates, // Material commands commands::material_cmd::upload_material, commands::material_cmd::list_materials, @@ -1268,6 +1276,15 @@ pub fn run() { commands::material_cmd::get_material_content, commands::material_cmd::get_material_count, commands::material_cmd::get_materials_content, + // Poster Material commands + commands::poster_material_cmd::create_poster_metadata, + commands::poster_material_cmd::get_poster_metadata, + commands::poster_material_cmd::get_poster_material, + commands::poster_material_cmd::list_by_image_category, + commands::poster_material_cmd::list_by_layout_category, + commands::poster_material_cmd::list_by_mood, + commands::poster_material_cmd::update_poster_metadata, + commands::poster_material_cmd::delete_poster_metadata, // Template commands commands::template_cmd::create_template, commands::template_cmd::list_templates, @@ -1276,6 +1293,14 @@ pub fn run() { commands::template_cmd::delete_template, commands::template_cmd::set_default_template, commands::template_cmd::get_default_template, + // A2UI Form commands + commands::a2ui_form_cmd::create_a2ui_form, + commands::a2ui_form_cmd::get_a2ui_form, + commands::a2ui_form_cmd::get_a2ui_forms_by_message, + commands::a2ui_form_cmd::get_a2ui_forms_by_session, + commands::a2ui_form_cmd::save_a2ui_form_data, + commands::a2ui_form_cmd::submit_a2ui_form, + commands::a2ui_form_cmd::delete_a2ui_form, // Content commands commands::content_cmd::content_create, commands::content_cmd::content_get, diff --git a/src-tauri/src/commands/a2ui_form_cmd.rs b/src-tauri/src/commands/a2ui_form_cmd.rs new file mode 100644 index 000000000..b4e87739d --- /dev/null +++ b/src-tauri/src/commands/a2ui_form_cmd.rs @@ -0,0 +1,107 @@ +//! A2UI 表单 Tauri 命令 +//! +//! 提供 A2UI 表单的前端 API,包括: +//! - 创建表单记录 +//! - 保存表单数据 +//! - 提交表单 +//! - 查询表单 + +use tauri::State; + +use crate::database::dao::a2ui_form_dao::{ + A2UIForm, A2UIFormDao, A2UIFormError, CreateA2UIFormRequest, +}; +use crate::database::DbConnection; + +// ============================================================================ +// 响应类型 +// ============================================================================ + +/// 命令结果类型 +type CmdResult = Result; + +/// 将 A2UIFormError 转换为字符串 +fn map_err(e: A2UIFormError) -> String { + e.to_string() +} + +// ============================================================================ +// Tauri 命令 +// ============================================================================ + +/// 创建 A2UI 表单记录 +#[tauri::command] +pub async fn create_a2ui_form( + db: State<'_, DbConnection>, + message_id: i64, + session_id: String, + a2ui_response_json: String, + form_data_json: Option, +) -> CmdResult { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + + let req = CreateA2UIFormRequest { + message_id, + session_id, + a2ui_response_json, + form_data_json, + }; + + A2UIFormDao::create(&conn, &req).map_err(map_err) +} + +/// 获取单个表单 +#[tauri::command] +pub async fn get_a2ui_form(db: State<'_, DbConnection>, id: String) -> CmdResult> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + A2UIFormDao::get(&conn, &id).map_err(map_err) +} + +/// 根据消息 ID 获取表单列表 +#[tauri::command] +pub async fn get_a2ui_forms_by_message( + db: State<'_, DbConnection>, + message_id: i64, +) -> CmdResult> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + A2UIFormDao::get_by_message(&conn, message_id).map_err(map_err) +} + +/// 根据会话 ID 获取所有表单 +#[tauri::command] +pub async fn get_a2ui_forms_by_session( + db: State<'_, DbConnection>, + session_id: String, +) -> CmdResult> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + A2UIFormDao::get_by_session(&conn, &session_id).map_err(map_err) +} + +/// 更新表单数据(用户填写的内容) +#[tauri::command] +pub async fn save_a2ui_form_data( + db: State<'_, DbConnection>, + id: String, + form_data_json: String, +) -> CmdResult { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + A2UIFormDao::update_form_data(&conn, &id, &form_data_json).map_err(map_err) +} + +/// 提交表单 +#[tauri::command] +pub async fn submit_a2ui_form( + db: State<'_, DbConnection>, + id: String, + form_data_json: String, +) -> CmdResult { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + A2UIFormDao::submit(&conn, &id, &form_data_json).map_err(map_err) +} + +/// 删除表单 +#[tauri::command] +pub async fn delete_a2ui_form(db: State<'_, DbConnection>, id: String) -> CmdResult<()> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + A2UIFormDao::delete(&conn, &id).map_err(map_err) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2e386e5ea..603afc889 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod a2ui_form_cmd; pub mod agent_cmd; pub mod api_key_provider_cmd; pub mod asr_cmd; @@ -28,6 +29,7 @@ pub mod persona_cmd; pub mod plugin_cmd; pub mod plugin_install_cmd; pub mod plugin_rpc_cmd; +pub mod poster_material_cmd; pub mod prompt_cmd; pub mod provider_pool_cmd; pub mod resilience_cmd; diff --git a/src-tauri/src/commands/persona_cmd.rs b/src-tauri/src/commands/persona_cmd.rs index e2860950b..89abbf807 100644 --- a/src-tauri/src/commands/persona_cmd.rs +++ b/src-tauri/src/commands/persona_cmd.rs @@ -4,6 +4,8 @@ //! - 创建、获取、列表、更新、删除人设 //! - 设置项目默认人设 //! - 获取人设模板列表 +//! - AI 一键生成人设 +//! - 品牌人设扩展管理 //! //! ## 相关需求 //! - Requirements 6.1: 人设列表显示 @@ -12,11 +14,16 @@ //! - Requirements 6.4: 设置默认人设 //! - Requirements 6.5: 人设模板 //! - Requirements 6.6: 人设删除确认 +//! - Requirements 6.7: AI 一键生成人设 +use serde::{Deserialize, Serialize}; use tauri::State; use crate::database::DbConnection; -use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate}; +use crate::models::project_model::{ + BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, CreateBrandExtensionRequest, + CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest, +}; use crate::services::persona_service::PersonaService; // ============================================================================ @@ -173,28 +180,21 @@ pub async fn delete_persona(db: State<'_, DbConnection>, id: String) -> Result<( /// /// # 参数 /// - `db`: 数据库连接状态 -/// - `project_id`: 项目 ID -/// - `persona_id`: 要设为默认的人设 ID +/// - `projectId`: 项目 ID +/// - `personaId`: 要设为默认的人设 ID /// /// # 返回 /// - 成功返回 () /// - 失败返回错误信息 -/// -/// # 示例(前端调用) -/// ```typescript -/// await invoke('set_default_persona', { -/// projectId: 'project-1', -/// personaId: 'persona-1' -/// }); -/// ``` #[tauri::command] +#[allow(non_snake_case)] pub async fn set_default_persona( db: State<'_, DbConnection>, - project_id: String, - persona_id: String, + projectId: String, + personaId: String, ) -> Result<(), String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PersonaService::set_default_persona(&conn, &project_id, &persona_id).map_err(|e| e.to_string()) + PersonaService::set_default_persona(&conn, &projectId, &personaId).map_err(|e| e.to_string()) } /// 获取人设模板列表 @@ -240,3 +240,349 @@ pub async fn get_default_persona( let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; PersonaService::get_default_persona(&conn, &project_id).map_err(|e| e.to_string()) } + +// ============================================================================ +// AI 生成人设 +// ============================================================================ + +/// AI 生成的人设结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedPersona { + /// 人设名称 + pub name: String, + /// 人设描述 + pub description: String, + /// 写作风格 + pub style: String, + /// 语气 + pub tone: String, + /// 目标受众 + pub target_audience: String, + /// 禁用词列表 + pub forbidden_words: Vec, + /// 偏好词列表 + pub preferred_words: Vec, +} + +/// AI 一键生成人设 +/// +/// 根据用户提供的简单描述,调用 AI 生成完整的人设配置。 +/// 自动从凭证池选择可用凭证进行调用。 +/// +/// # 参数 +/// - `prompt`: 用户描述,例如"一个幽默风趣的科技博主" +/// +/// # 返回 +/// - 成功返回生成的人设配置 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn generate_persona( + agent_state: State<'_, crate::agent::AsterAgentState>, + db: State<'_, DbConnection>, + prompt: String, +) -> Result { + use aster::conversation::message::Message; + use futures::StreamExt; + + tracing::info!("[Persona] AI 生成人设: prompt={}", prompt); + + // 确保 Agent 已初始化 + if !agent_state.is_initialized().await { + agent_state.init_agent_with_db(&db).await?; + } + + // 创建临时会话 ID + let session_id = format!("persona-gen-{}", uuid::Uuid::new_v4()); + + // 如果 Provider 未配置,自动从凭证池选择一个 + if !agent_state.is_provider_configured().await { + tracing::info!("[Persona] Provider 未配置,尝试从凭证池自动选择"); + + // 尝试按优先级选择 Provider: deepseek > openai > anthropic > kiro + let provider_types = ["deepseek", "openai", "anthropic", "kiro"]; + let default_models = [ + "deepseek-chat", + "gpt-4o-mini", + "claude-3-haiku-20240307", + "anthropic.claude-3-haiku-20240307-v1:0", + ]; + + let mut configured = false; + for (provider_type, model) in provider_types.iter().zip(default_models.iter()) { + match agent_state + .configure_provider_from_pool(&db, provider_type, model, &session_id) + .await + { + Ok(_) => { + tracing::info!( + "[Persona] 自动配置 Provider 成功: {} / {}", + provider_type, + model + ); + configured = true; + break; + } + Err(e) => { + tracing::debug!( + "[Persona] 尝试 {} 失败: {}, 继续尝试下一个", + provider_type, + e + ); + } + } + } + + if !configured { + return Err("没有可用的 AI 凭证,请先在设置中添加凭证".to_string()); + } + } + + let system_prompt = r#"你是一个专业的内容创作人设设计师。根据用户的描述,生成一个完整的创作人设配置。 + +请严格按照以下 JSON 格式返回(不要包含任何其他文字,不要使用 markdown 代码块): +{"name":"人设名称","description":"人设描述(50字以内)","style":"写作风格","tone":"语气","targetAudience":"目标受众","forbiddenWords":["禁用词1","禁用词2"],"preferredWords":["偏好词1","偏好词2"]} + +注意: +1. 名称要有特色,能体现人设特点 +2. 禁用词是创作时应避免的词汇 +3. 偏好词是创作时优先使用的词汇 +4. 直接返回 JSON,不要任何额外文字"#; + + let user_prompt = format!("{}\n\n请为以下描述生成人设配置:{}", system_prompt, prompt); + + let cancel_token = agent_state.create_cancel_token(&session_id).await; + + let user_message = Message::user().with_text(&user_prompt); + let session_config = crate::agent::aster_state::SessionConfigBuilder::new(&session_id).build(); + + // 获取 Agent 引用 + let agent_arc = agent_state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent 未初始化")?; + + // 调用 Agent + let stream_result = agent + .reply(user_message, session_config, Some(cancel_token.clone())) + .await; + + let mut full_content = String::new(); + + match stream_result { + Ok(mut stream) => { + while let Some(event_result) = stream.next().await { + match event_result { + Ok(agent_event) => { + // 提取文本内容 + if let aster::agents::AgentEvent::Message(message) = agent_event { + for content in &message.content { + if let aster::conversation::message::MessageContent::Text( + text_content, + ) = content + { + full_content.push_str(&text_content.text); + } + } + } + } + Err(e) => { + tracing::error!("[Persona] 流错误: {}", e); + } + } + } + } + Err(e) => { + agent_state.remove_cancel_token(&session_id).await; + return Err(format!("AI 调用失败: {e}")); + } + } + + // 清理取消令牌 + agent_state.remove_cancel_token(&session_id).await; + + if full_content.is_empty() { + return Err("AI 返回空内容".to_string()); + } + + tracing::debug!("[Persona] AI 返回内容: {}", full_content); + + // 解析 AI 返回的 JSON + let persona: GeneratedPersona = parse_persona_json(&full_content)?; + + tracing::info!("[Persona] AI 生成人设成功: name={}", persona.name); + + Ok(persona) +} + +/// 解析 AI 返回的人设 JSON +fn parse_persona_json(content: &str) -> Result { + // 尝试提取 JSON 部分(AI 可能返回额外文字) + let json_str = extract_json(content); + + serde_json::from_str(&json_str).map_err(|e| { + tracing::error!("[Persona] JSON 解析失败: {}, content: {}", e, content); + format!("解析人设配置失败: {e}") + }) +} + +/// 从文本中提取 JSON +fn extract_json(content: &str) -> String { + // 查找 JSON 对象的开始和结束 + if let Some(start) = content.find('{') { + if let Some(end) = content.rfind('}') { + if end > start { + return content[start..=end].to_string(); + } + } + } + content.to_string() +} + +// ============================================================================ +// 品牌人设扩展命令 +// ============================================================================ + +/// 获取品牌人设(基础人设 + 扩展) +/// +/// 获取完整的品牌人设信息,包括基础人设和品牌扩展字段。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `persona_id`: 人设 ID +/// +/// # 返回 +/// - 成功返回 Option +/// - 失败返回错误信息 +/// +/// # 示例(前端调用) +/// ```typescript +/// const brandPersona = await invoke('get_brand_persona', { +/// personaId: 'persona-1' +/// }); +/// ``` +#[tauri::command] +pub async fn get_brand_persona( + db: State<'_, DbConnection>, + persona_id: String, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PersonaService::get_brand_persona(&conn, &persona_id).map_err(|e| e.to_string()) +} + +/// 获取品牌人设扩展 +/// +/// 仅获取品牌扩展字段,不包括基础人设。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `persona_id`: 人设 ID +/// +/// # 返回 +/// - 成功返回 Option +/// - 失败返回错误信息 +#[tauri::command] +pub async fn get_brand_extension( + db: State<'_, DbConnection>, + persona_id: String, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PersonaService::get_brand_extension(&conn, &persona_id).map_err(|e| e.to_string()) +} + +/// 保存品牌人设扩展 +/// +/// 创建或更新品牌人设扩展。如果扩展不存在则创建,存在则更新。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `req`: 创建/更新请求 +/// +/// # 返回 +/// - 成功返回保存后的扩展 +/// - 失败返回错误信息 +/// +/// # 示例(前端调用) +/// ```typescript +/// const extension = await invoke('save_brand_extension', { +/// req: { +/// personaId: 'persona-1', +/// brandTone: { +/// keywords: ['专业', '可信赖'], +/// personality: 'professional', +/// voiceTone: '专业但不冷漠', +/// }, +/// design: { +/// primaryStyle: 'modern', +/// colorScheme: { ... }, +/// typography: { ... }, +/// }, +/// } +/// }); +/// ``` +#[tauri::command] +pub async fn save_brand_extension( + db: State<'_, DbConnection>, + req: CreateBrandExtensionRequest, +) -> Result { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PersonaService::save_brand_extension(&conn, req).map_err(|e| e.to_string()) +} + +/// 更新品牌人设扩展 +/// +/// 更新已存在的品牌人设扩展。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `persona_id`: 人设 ID +/// - `update`: 更新内容 +/// +/// # 返回 +/// - 成功返回更新后的扩展 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn update_brand_extension( + db: State<'_, DbConnection>, + persona_id: String, + update: UpdateBrandExtensionRequest, +) -> Result { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PersonaService::update_brand_extension(&conn, &persona_id, update).map_err(|e| e.to_string()) +} + +/// 删除品牌人设扩展 +/// +/// 删除指定人设的品牌扩展,不影响基础人设。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `persona_id`: 人设 ID +/// +/// # 返回 +/// - 成功返回 () +/// - 失败返回错误信息 +#[tauri::command] +pub async fn delete_brand_extension( + db: State<'_, DbConnection>, + persona_id: String, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PersonaService::delete_brand_extension(&conn, &persona_id).map_err(|e| e.to_string()) +} + +/// 获取品牌人设模板列表 +/// +/// 获取预定义的品牌人设模板,用于快速创建品牌人设。 +/// 模板包含电商促销、品牌形象、社交媒体、活动宣传等场景。 +/// +/// # 返回 +/// - 品牌人设模板列表 +/// +/// # 示例(前端调用) +/// ```typescript +/// const templates = await invoke('list_brand_persona_templates'); +/// ``` +#[tauri::command] +pub async fn list_brand_persona_templates() -> Result, String> { + Ok(PersonaService::list_brand_persona_templates()) +} diff --git a/src-tauri/src/commands/poster_material_cmd.rs b/src-tauri/src/commands/poster_material_cmd.rs new file mode 100644 index 000000000..e4a418e15 --- /dev/null +++ b/src-tauri/src/commands/poster_material_cmd.rs @@ -0,0 +1,188 @@ +//! 海报素材相关的 Tauri 命令 +//! +//! 提供海报素材元数据(PosterMaterialMetadata)管理的前端 API,包括: +//! - 创建、获取、更新、删除海报素材元数据 +//! - 按分类筛选素材 + +use tauri::State; + +use crate::database::dao::poster_material_dao::PosterMaterialDao; +use crate::database::DbConnection; +use crate::models::project_model::{ + CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata, +}; + +// ============================================================================ +// Tauri 命令 +// ============================================================================ + +/// 创建海报素材元数据 +/// +/// 为已存在的素材创建海报专用元数据。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `req`: 创建请求 +/// +/// # 返回 +/// - 成功返回创建的元数据 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn create_poster_metadata( + db: State<'_, DbConnection>, + req: CreatePosterMetadataRequest, +) -> Result { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::create(&conn, &req).map_err(|e| e.to_string()) +} + +/// 获取海报素材元数据 +/// +/// 根据素材 ID 获取海报元数据。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `material_id`: 素材 ID +/// +/// # 返回 +/// - 成功返回 Option +/// - 失败返回错误信息 +#[tauri::command] +pub async fn get_poster_metadata( + db: State<'_, DbConnection>, + material_id: String, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::get(&conn, &material_id).map_err(|e| e.to_string()) +} + +/// 获取完整的海报素材 +/// +/// 获取包含基础素材和元数据的完整海报素材。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `material_id`: 素材 ID +/// +/// # 返回 +/// - 成功返回 Option +/// - 失败返回错误信息 +#[tauri::command] +pub async fn get_poster_material( + db: State<'_, DbConnection>, + material_id: String, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::get_poster_material(&conn, &material_id).map_err(|e| e.to_string()) +} + +/// 按图片分类获取素材列表 +/// +/// 获取指定项目下的图片素材,可按分类筛选。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `project_id`: 项目 ID +/// - `category`: 可选的图片分类 +/// +/// # 返回 +/// - 成功返回海报素材列表 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn list_by_image_category( + db: State<'_, DbConnection>, + project_id: String, + category: Option, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::list_by_image_category(&conn, &project_id, category.as_deref()) + .map_err(|e| e.to_string()) +} + +/// 按布局分类获取素材列表 +/// +/// 获取指定项目下的布局素材,可按分类筛选。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `project_id`: 项目 ID +/// - `category`: 可选的布局分类 +/// +/// # 返回 +/// - 成功返回海报素材列表 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn list_by_layout_category( + db: State<'_, DbConnection>, + project_id: String, + category: Option, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::list_by_layout_category(&conn, &project_id, category.as_deref()) + .map_err(|e| e.to_string()) +} + +/// 按配色氛围获取素材列表 +/// +/// 获取指定项目下的配色素材,可按氛围筛选。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `project_id`: 项目 ID +/// - `mood`: 可选的配色氛围 +/// +/// # 返回 +/// - 成功返回海报素材列表 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn list_by_mood( + db: State<'_, DbConnection>, + project_id: String, + mood: Option, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::list_by_mood(&conn, &project_id, mood.as_deref()).map_err(|e| e.to_string()) +} + +/// 更新海报素材元数据 +/// +/// 更新指定素材的海报元数据。如果元数据不存在,则创建新的。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `material_id`: 素材 ID +/// - `req`: 更新请求 +/// +/// # 返回 +/// - 成功返回更新后的元数据 +/// - 失败返回错误信息 +#[tauri::command] +pub async fn update_poster_metadata( + db: State<'_, DbConnection>, + material_id: String, + req: CreatePosterMetadataRequest, +) -> Result { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::update(&conn, &material_id, &req).map_err(|e| e.to_string()) +} + +/// 删除海报素材元数据 +/// +/// 删除指定素材的海报元数据。 +/// 注意:这只删除元数据,不删除基础素材。 +/// +/// # 参数 +/// - `db`: 数据库连接状态 +/// - `material_id`: 素材 ID +/// +/// # 返回 +/// - 成功返回 () +/// - 失败返回错误信息 +#[tauri::command] +pub async fn delete_poster_metadata( + db: State<'_, DbConnection>, + material_id: String, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + PosterMaterialDao::delete(&conn, &material_id).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/database/dao/a2ui_form_dao.rs b/src-tauri/src/database/dao/a2ui_form_dao.rs new file mode 100644 index 000000000..febbb2e14 --- /dev/null +++ b/src-tauri/src/database/dao/a2ui_form_dao.rs @@ -0,0 +1,280 @@ +//! A2UI 表单数据访问层 +//! +//! 提供 A2UI 表单的 CRUD 操作,包括: +//! - 创建、获取、更新、删除表单 +//! - 按会话/消息查询表单 +//! - 更新表单数据和提交状态 + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ============================================================================ +// 数据模型 +// ============================================================================ + +/// A2UI 表单记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct A2UIForm { + /// 表单 ID + pub id: String, + /// 关联的消息 ID + pub message_id: i64, + /// 关联的会话 ID + pub session_id: String, + /// A2UI 响应 JSON(包含组件定义) + pub a2ui_response_json: String, + /// 用户填写的表单数据 JSON + pub form_data_json: String, + /// 是否已提交 + pub submitted: bool, + /// 提交时间 + pub submitted_at: Option, + /// 创建时间 + pub created_at: i64, + /// 更新时间 + pub updated_at: i64, +} + +/// 创建 A2UI 表单请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateA2UIFormRequest { + /// 关联的消息 ID + pub message_id: i64, + /// 关联的会话 ID + pub session_id: String, + /// A2UI 响应 JSON + pub a2ui_response_json: String, + /// 初始表单数据(可选) + pub form_data_json: Option, +} + +/// 更新表单数据请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateFormDataRequest { + /// 表单数据 JSON + pub form_data_json: String, +} + +// ============================================================================ +// 错误类型 +// ============================================================================ + +/// A2UI 表单错误 +#[derive(Debug, thiserror::Error)] +pub enum A2UIFormError { + #[error("表单不存在: {0}")] + NotFound(String), + + #[error("数据库错误: {0}")] + Database(#[from] rusqlite::Error), +} + +// ============================================================================ +// 数据访问对象 +// ============================================================================ + +/// A2UI 表单 DAO +pub struct A2UIFormDao; + +impl A2UIFormDao { + // ------------------------------------------------------------------------ + // 创建表单 + // ------------------------------------------------------------------------ + + /// 创建新的 A2UI 表单记录 + pub fn create( + conn: &Connection, + req: &CreateA2UIFormRequest, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp(); + let form_data = req + .form_data_json + .clone() + .unwrap_or_else(|| "{}".to_string()); + + conn.execute( + "INSERT INTO a2ui_forms ( + id, message_id, session_id, a2ui_response_json, form_data_json, + submitted, submitted_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + id, + req.message_id, + req.session_id, + req.a2ui_response_json, + form_data, + 0, // submitted = false + Option::::None, + now, + now, + ], + )?; + + Ok(A2UIForm { + id, + message_id: req.message_id, + session_id: req.session_id.clone(), + a2ui_response_json: req.a2ui_response_json.clone(), + form_data_json: form_data, + submitted: false, + submitted_at: None, + created_at: now, + updated_at: now, + }) + } + + // ------------------------------------------------------------------------ + // 获取表单 + // ------------------------------------------------------------------------ + + /// 根据 ID 获取表单 + pub fn get(conn: &Connection, id: &str) -> Result, A2UIFormError> { + let mut stmt = conn.prepare( + "SELECT id, message_id, session_id, a2ui_response_json, form_data_json, + submitted, submitted_at, created_at, updated_at + FROM a2ui_forms WHERE id = ?", + )?; + + let mut rows = stmt.query([id])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::map_row(row)?)) + } else { + Ok(None) + } + } + + /// 根据消息 ID 获取表单列表 + pub fn get_by_message( + conn: &Connection, + message_id: i64, + ) -> Result, A2UIFormError> { + let mut stmt = conn.prepare( + "SELECT id, message_id, session_id, a2ui_response_json, form_data_json, + submitted, submitted_at, created_at, updated_at + FROM a2ui_forms WHERE message_id = ? ORDER BY created_at ASC", + )?; + + let forms: Vec = stmt + .query_map([message_id], |row| Self::map_row(row))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(forms) + } + + /// 根据会话 ID 获取所有表单 + pub fn get_by_session( + conn: &Connection, + session_id: &str, + ) -> Result, A2UIFormError> { + let mut stmt = conn.prepare( + "SELECT id, message_id, session_id, a2ui_response_json, form_data_json, + submitted, submitted_at, created_at, updated_at + FROM a2ui_forms WHERE session_id = ? ORDER BY created_at ASC", + )?; + + let forms: Vec = stmt + .query_map([session_id], |row| Self::map_row(row))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(forms) + } + + // ------------------------------------------------------------------------ + // 更新表单 + // ------------------------------------------------------------------------ + + /// 更新表单数据(用户填写的内容) + pub fn update_form_data( + conn: &Connection, + id: &str, + form_data_json: &str, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + + let rows = conn.execute( + "UPDATE a2ui_forms SET form_data_json = ?1, updated_at = ?2 WHERE id = ?3", + params![form_data_json, now, id], + )?; + + if rows == 0 { + return Err(A2UIFormError::NotFound(id.to_string())); + } + + Self::get(conn, id)?.ok_or_else(|| A2UIFormError::NotFound(id.to_string())) + } + + /// 提交表单 + pub fn submit( + conn: &Connection, + id: &str, + form_data_json: &str, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + let submitted_at = chrono::Utc::now().to_rfc3339(); + + let rows = conn.execute( + "UPDATE a2ui_forms SET + form_data_json = ?1, + submitted = 1, + submitted_at = ?2, + updated_at = ?3 + WHERE id = ?4", + params![form_data_json, submitted_at, now, id], + )?; + + if rows == 0 { + return Err(A2UIFormError::NotFound(id.to_string())); + } + + Self::get(conn, id)?.ok_or_else(|| A2UIFormError::NotFound(id.to_string())) + } + + // ------------------------------------------------------------------------ + // 删除表单 + // ------------------------------------------------------------------------ + + /// 删除表单 + pub fn delete(conn: &Connection, id: &str) -> Result<(), A2UIFormError> { + let rows = conn.execute("DELETE FROM a2ui_forms WHERE id = ?", [id])?; + + if rows == 0 { + return Err(A2UIFormError::NotFound(id.to_string())); + } + + Ok(()) + } + + /// 删除会话的所有表单 + pub fn delete_by_session(conn: &Connection, session_id: &str) -> Result { + let rows = conn.execute("DELETE FROM a2ui_forms WHERE session_id = ?", [session_id])?; + + Ok(rows as u64) + } + + // ------------------------------------------------------------------------ + // 辅助方法 + // ------------------------------------------------------------------------ + + /// 映射数据库行到 A2UIForm 结构体 + fn map_row(row: &rusqlite::Row) -> Result { + Ok(A2UIForm { + id: row.get(0)?, + message_id: row.get(1)?, + session_id: row.get(2)?, + a2ui_response_json: row.get(3)?, + form_data_json: row.get(4)?, + submitted: row.get::<_, i32>(5)? != 0, + submitted_at: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) + } +} diff --git a/src-tauri/src/database/dao/brand_persona_dao.rs b/src-tauri/src/database/dao/brand_persona_dao.rs new file mode 100644 index 000000000..c5ecb0bad --- /dev/null +++ b/src-tauri/src/database/dao/brand_persona_dao.rs @@ -0,0 +1,688 @@ +//! 品牌人设扩展数据访问层 +//! +//! 提供品牌人设扩展(BrandPersonaExtension)的 CRUD 操作,包括: +//! - 创建、获取、更新、删除品牌人设扩展 +//! - 获取完整的品牌人设(基础人设 + 扩展) + +use rusqlite::{params, Connection}; +use uuid::Uuid; + +use crate::errors::project_error::PersonaError; +use crate::models::project_model::{ + BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, BrandTone, + CreateBrandExtensionRequest, DesignConfig, Persona, UpdateBrandExtensionRequest, VisualConfig, +}; + +use super::persona_dao::PersonaDao; + +// ============================================================================ +// 数据访问对象 +// ============================================================================ + +/// 品牌人设扩展 DAO +/// +/// 提供品牌人设扩展的数据库操作方法。 +pub struct BrandPersonaDao; + +impl BrandPersonaDao { + // ------------------------------------------------------------------------ + // 创建品牌人设扩展 + // ------------------------------------------------------------------------ + + /// 创建品牌人设扩展 + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `req`: 创建请求 + /// + /// # 返回 + /// - 成功返回创建的扩展 + /// - 失败返回 PersonaError + pub fn create( + conn: &Connection, + req: &CreateBrandExtensionRequest, + ) -> Result { + // 验证人设存在 + PersonaDao::get(conn, &req.persona_id)? + .ok_or_else(|| PersonaError::NotFound(req.persona_id.clone()))?; + + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp(); + + // 序列化 JSON 字段 + let brand_tone = req.brand_tone.clone().unwrap_or_default(); + let design = req.design.clone().unwrap_or_default(); + let visual = req.visual.clone().unwrap_or_default(); + + let brand_tone_json = + serde_json::to_string(&brand_tone).unwrap_or_else(|_| "{}".to_string()); + let design_json = serde_json::to_string(&design).unwrap_or_else(|_| "{}".to_string()); + let visual_json = serde_json::to_string(&visual).unwrap_or_else(|_| "{}".to_string()); + + conn.execute( + "INSERT INTO brand_persona_extensions ( + id, persona_id, brand_tone_json, design_json, visual_json, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + id, + req.persona_id, + brand_tone_json, + design_json, + visual_json, + now, + now, + ], + )?; + + Ok(BrandPersonaExtension { + persona_id: req.persona_id.clone(), + brand_tone, + design, + visual, + created_at: now, + updated_at: now, + }) + } + + // ------------------------------------------------------------------------ + // 获取品牌人设扩展 + // ------------------------------------------------------------------------ + + /// 获取品牌人设扩展 + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `persona_id`: 人设 ID + /// + /// # 返回 + /// - 成功返回 Option + /// - 失败返回 PersonaError + pub fn get( + conn: &Connection, + persona_id: &str, + ) -> Result, PersonaError> { + let mut stmt = conn.prepare( + "SELECT persona_id, brand_tone_json, design_json, visual_json, created_at, updated_at + FROM brand_persona_extensions WHERE persona_id = ?", + )?; + + let mut rows = stmt.query([persona_id])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::map_row(row)?)) + } else { + Ok(None) + } + } + + /// 获取完整的品牌人设(基础人设 + 扩展) + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `persona_id`: 人设 ID + /// + /// # 返回 + /// - 成功返回 Option + /// - 失败返回 PersonaError + pub fn get_brand_persona( + conn: &Connection, + persona_id: &str, + ) -> Result, PersonaError> { + // 获取基础人设 + let base = match PersonaDao::get(conn, persona_id)? { + Some(p) => p, + None => return Ok(None), + }; + + // 获取扩展 + let extension = Self::get(conn, persona_id)?; + + Ok(Some(BrandPersona { + base, + brand_tone: extension.as_ref().map(|e| e.brand_tone.clone()), + design: extension.as_ref().map(|e| e.design.clone()), + visual: extension.as_ref().map(|e| e.visual.clone()), + })) + } + + // ------------------------------------------------------------------------ + // 更新品牌人设扩展 + // ------------------------------------------------------------------------ + + /// 更新品牌人设扩展 + /// + /// 如果扩展不存在,则创建新的扩展。 + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `persona_id`: 人设 ID + /// - `update`: 更新内容 + /// + /// # 返回 + /// - 成功返回更新后的扩展 + /// - 失败返回 PersonaError + pub fn update( + conn: &Connection, + persona_id: &str, + update: &UpdateBrandExtensionRequest, + ) -> Result { + // 验证人设存在 + PersonaDao::get(conn, persona_id)? + .ok_or_else(|| PersonaError::NotFound(persona_id.to_string()))?; + + // 检查扩展是否存在 + let existing = Self::get(conn, persona_id)?; + + if existing.is_none() { + // 创建新扩展 + let req = CreateBrandExtensionRequest { + persona_id: persona_id.to_string(), + brand_tone: update.brand_tone.clone(), + design: update.design.clone(), + visual: update.visual.clone(), + }; + return Self::create(conn, &req); + } + + let existing = existing.unwrap(); + let now = chrono::Utc::now().timestamp(); + + // 构建更新后的值 + let brand_tone = update.brand_tone.clone().unwrap_or(existing.brand_tone); + let design = update.design.clone().unwrap_or(existing.design); + let visual = update.visual.clone().unwrap_or(existing.visual); + + // 序列化 JSON 字段 + let brand_tone_json = + serde_json::to_string(&brand_tone).unwrap_or_else(|_| "{}".to_string()); + let design_json = serde_json::to_string(&design).unwrap_or_else(|_| "{}".to_string()); + let visual_json = serde_json::to_string(&visual).unwrap_or_else(|_| "{}".to_string()); + + conn.execute( + "UPDATE brand_persona_extensions SET + brand_tone_json = ?1, design_json = ?2, visual_json = ?3, updated_at = ?4 + WHERE persona_id = ?5", + params![brand_tone_json, design_json, visual_json, now, persona_id,], + )?; + + Ok(BrandPersonaExtension { + persona_id: persona_id.to_string(), + brand_tone, + design, + visual, + created_at: existing.created_at, + updated_at: now, + }) + } + + // ------------------------------------------------------------------------ + // 删除品牌人设扩展 + // ------------------------------------------------------------------------ + + /// 删除品牌人设扩展 + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `persona_id`: 人设 ID + /// + /// # 返回 + /// - 成功返回 () + /// - 失败返回 PersonaError + pub fn delete(conn: &Connection, persona_id: &str) -> Result<(), PersonaError> { + conn.execute( + "DELETE FROM brand_persona_extensions WHERE persona_id = ?", + [persona_id], + )?; + Ok(()) + } + + // ------------------------------------------------------------------------ + // 辅助方法 + // ------------------------------------------------------------------------ + + /// 映射数据库行到 BrandPersonaExtension 结构体 + fn map_row(row: &rusqlite::Row) -> Result { + let brand_tone_json: String = row.get(1)?; + let design_json: String = row.get(2)?; + let visual_json: String = row.get(3)?; + + // 解析 JSON 字段 + let brand_tone: BrandTone = serde_json::from_str(&brand_tone_json).unwrap_or_default(); + let design: DesignConfig = serde_json::from_str(&design_json).unwrap_or_default(); + let visual: VisualConfig = serde_json::from_str(&visual_json).unwrap_or_default(); + + Ok(BrandPersonaExtension { + persona_id: row.get(0)?, + brand_tone, + design, + visual, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + } + + // ------------------------------------------------------------------------ + // 品牌人设模板 + // ------------------------------------------------------------------------ + + /// 获取预定义的品牌人设模板列表 + pub fn list_templates() -> Vec { + vec![ + BrandPersonaTemplate { + id: "ecommerce-promo".to_string(), + name: "电商促销".to_string(), + description: "适合电商促销、限时优惠等场景".to_string(), + brand_tone: BrandTone { + keywords: vec!["实惠".to_string(), "限时".to_string(), "优惠".to_string()], + personality: "bold".to_string(), + voice_tone: Some("紧迫感、吸引力".to_string()), + target_audience: Some("追求性价比的消费者".to_string()), + }, + design: DesignConfig { + primary_style: "bold".to_string(), + color_scheme: crate::models::project_model::ColorScheme { + primary: "#FF4757".to_string(), + secondary: "#FFA502".to_string(), + accent: "#FF6348".to_string(), + background: "#FFFFFF".to_string(), + text: "#2F3542".to_string(), + text_secondary: "#57606F".to_string(), + gradients: None, + }, + typography: crate::models::project_model::Typography { + title_font: "阿里巴巴普惠体".to_string(), + title_weight: 700, + body_font: "思源黑体".to_string(), + body_weight: 400, + title_size: 80, + body_size: 24, + line_height: 1.4, + letter_spacing: 0.0, + }, + }, + visual: None, + }, + BrandPersonaTemplate { + id: "brand-image".to_string(), + name: "品牌形象".to_string(), + description: "适合品牌宣传、企业形象展示".to_string(), + brand_tone: BrandTone { + keywords: vec!["专业".to_string(), "可信赖".to_string(), "品质".to_string()], + personality: "professional".to_string(), + voice_tone: Some("专业但不冷漠".to_string()), + target_audience: Some("注重品质的消费者".to_string()), + }, + design: DesignConfig { + primary_style: "modern".to_string(), + color_scheme: crate::models::project_model::ColorScheme { + primary: "#2196F3".to_string(), + secondary: "#90CAF9".to_string(), + accent: "#1976D2".to_string(), + background: "#FFFFFF".to_string(), + text: "#212121".to_string(), + text_secondary: "#757575".to_string(), + gradients: None, + }, + typography: crate::models::project_model::Typography { + title_font: "思源黑体".to_string(), + title_weight: 600, + body_font: "苹方".to_string(), + body_weight: 400, + title_size: 64, + body_size: 20, + line_height: 1.6, + letter_spacing: 1.0, + }, + }, + visual: None, + }, + BrandPersonaTemplate { + id: "social-media".to_string(), + name: "社交媒体".to_string(), + description: "适合小红书、抖音等社交平台".to_string(), + brand_tone: BrandTone { + keywords: vec!["年轻".to_string(), "时尚".to_string(), "潮流".to_string()], + personality: "playful".to_string(), + voice_tone: Some("轻松活泼、有趣".to_string()), + target_audience: Some("18-30岁年轻人".to_string()), + }, + design: DesignConfig { + primary_style: "playful".to_string(), + color_scheme: crate::models::project_model::ColorScheme { + primary: "#FF6B9D".to_string(), + secondary: "#FFC0D0".to_string(), + accent: "#FF4081".to_string(), + background: "#FFFFFF".to_string(), + text: "#333333".to_string(), + text_secondary: "#666666".to_string(), + gradients: None, + }, + typography: crate::models::project_model::Typography { + title_font: "站酷快乐体".to_string(), + title_weight: 400, + body_font: "思源黑体".to_string(), + body_weight: 400, + title_size: 72, + body_size: 22, + line_height: 1.5, + letter_spacing: 0.0, + }, + }, + visual: None, + }, + BrandPersonaTemplate { + id: "event-promo".to_string(), + name: "活动宣传".to_string(), + description: "适合活动宣传、节日促销".to_string(), + brand_tone: BrandTone { + keywords: vec!["热闹".to_string(), "参与".to_string(), "精彩".to_string()], + personality: "bold".to_string(), + voice_tone: Some("热情洋溢、感染力强".to_string()), + target_audience: Some("活动目标参与者".to_string()), + }, + design: DesignConfig { + primary_style: "bold".to_string(), + color_scheme: crate::models::project_model::ColorScheme { + primary: "#FF9500".to_string(), + secondary: "#FFD166".to_string(), + accent: "#EF476F".to_string(), + background: "#FFFFFF".to_string(), + text: "#2D3436".to_string(), + text_secondary: "#636E72".to_string(), + gradients: None, + }, + typography: crate::models::project_model::Typography { + title_font: "站酷庆科黄油体".to_string(), + title_weight: 400, + body_font: "思源黑体".to_string(), + body_weight: 400, + title_size: 80, + body_size: 24, + line_height: 1.4, + letter_spacing: 0.0, + }, + }, + visual: None, + }, + ] + } +} + +// ============================================================================ +// 测试 +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use crate::models::project_model::CreatePersonaRequest; + + /// 创建测试数据库连接 + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + conn + } + + /// 创建测试项目 + fn create_test_project(conn: &Connection, id: &str) { + let now = chrono::Utc::now().timestamp(); + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + id, + "测试项目", + "persistent", + format!("/test/{}", id), + now, + now + ], + ) + .unwrap(); + } + + /// 创建测试人设 + fn create_test_persona(conn: &Connection, project_id: &str) -> Persona { + let req = CreatePersonaRequest { + project_id: project_id.to_string(), + name: "测试人设".to_string(), + description: None, + style: "专业".to_string(), + tone: None, + target_audience: None, + forbidden_words: None, + preferred_words: None, + examples: None, + platforms: None, + }; + PersonaDao::create(conn, &req).unwrap() + } + + #[test] + fn test_create_brand_extension() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + let req = CreateBrandExtensionRequest { + persona_id: persona.id.clone(), + brand_tone: Some(BrandTone { + keywords: vec!["专业".to_string(), "可信赖".to_string()], + personality: "professional".to_string(), + voice_tone: Some("专业但不冷漠".to_string()), + target_audience: Some("技术人员".to_string()), + }), + design: None, + visual: None, + }; + + let extension = BrandPersonaDao::create(&conn, &req).unwrap(); + + assert_eq!(extension.persona_id, persona.id); + assert_eq!(extension.brand_tone.keywords.len(), 2); + assert_eq!(extension.brand_tone.personality, "professional"); + } + + #[test] + fn test_get_brand_extension() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + let req = CreateBrandExtensionRequest { + persona_id: persona.id.clone(), + brand_tone: Some(BrandTone::default()), + design: Some(DesignConfig::default()), + visual: Some(VisualConfig::default()), + }; + + BrandPersonaDao::create(&conn, &req).unwrap(); + + let extension = BrandPersonaDao::get(&conn, &persona.id).unwrap(); + assert!(extension.is_some()); + let extension = extension.unwrap(); + assert_eq!(extension.persona_id, persona.id); + } + + #[test] + fn test_get_nonexistent_extension() { + let conn = setup_test_db(); + let result = BrandPersonaDao::get(&conn, "nonexistent").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_get_brand_persona() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + // 创建扩展 + let req = CreateBrandExtensionRequest { + persona_id: persona.id.clone(), + brand_tone: Some(BrandTone { + keywords: vec!["测试".to_string()], + personality: "friendly".to_string(), + voice_tone: None, + target_audience: None, + }), + design: None, + visual: None, + }; + BrandPersonaDao::create(&conn, &req).unwrap(); + + // 获取完整品牌人设 + let brand_persona = BrandPersonaDao::get_brand_persona(&conn, &persona.id).unwrap(); + assert!(brand_persona.is_some()); + let brand_persona = brand_persona.unwrap(); + + assert_eq!(brand_persona.base.id, persona.id); + assert!(brand_persona.brand_tone.is_some()); + assert_eq!(brand_persona.brand_tone.unwrap().personality, "friendly"); + } + + #[test] + fn test_get_brand_persona_without_extension() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + // 获取没有扩展的品牌人设 + let brand_persona = BrandPersonaDao::get_brand_persona(&conn, &persona.id).unwrap(); + assert!(brand_persona.is_some()); + let brand_persona = brand_persona.unwrap(); + + assert_eq!(brand_persona.base.id, persona.id); + assert!(brand_persona.brand_tone.is_none()); + assert!(brand_persona.design.is_none()); + assert!(brand_persona.visual.is_none()); + } + + #[test] + fn test_update_brand_extension() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + // 创建扩展 + let req = CreateBrandExtensionRequest { + persona_id: persona.id.clone(), + brand_tone: Some(BrandTone { + keywords: vec!["原始".to_string()], + personality: "professional".to_string(), + voice_tone: None, + target_audience: None, + }), + design: None, + visual: None, + }; + BrandPersonaDao::create(&conn, &req).unwrap(); + + // 更新扩展 + let update = UpdateBrandExtensionRequest { + brand_tone: Some(BrandTone { + keywords: vec!["更新".to_string(), "测试".to_string()], + personality: "friendly".to_string(), + voice_tone: Some("亲切".to_string()), + target_audience: None, + }), + design: None, + visual: None, + }; + + let updated = BrandPersonaDao::update(&conn, &persona.id, &update).unwrap(); + + assert_eq!(updated.brand_tone.keywords.len(), 2); + assert_eq!(updated.brand_tone.personality, "friendly"); + assert_eq!(updated.brand_tone.voice_tone, Some("亲切".to_string())); + } + + #[test] + fn test_update_creates_extension_if_not_exists() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + // 直接更新(不先创建) + let update = UpdateBrandExtensionRequest { + brand_tone: Some(BrandTone { + keywords: vec!["新建".to_string()], + personality: "bold".to_string(), + voice_tone: None, + target_audience: None, + }), + design: None, + visual: None, + }; + + let result = BrandPersonaDao::update(&conn, &persona.id, &update).unwrap(); + + assert_eq!(result.brand_tone.keywords, vec!["新建".to_string()]); + assert_eq!(result.brand_tone.personality, "bold"); + } + + #[test] + fn test_delete_brand_extension() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + // 创建扩展 + let req = CreateBrandExtensionRequest { + persona_id: persona.id.clone(), + brand_tone: Some(BrandTone::default()), + design: None, + visual: None, + }; + BrandPersonaDao::create(&conn, &req).unwrap(); + + // 验证存在 + assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_some()); + + // 删除 + BrandPersonaDao::delete(&conn, &persona.id).unwrap(); + + // 验证已删除 + assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_none()); + } + + #[test] + fn test_list_templates() { + let templates = BrandPersonaDao::list_templates(); + assert_eq!(templates.len(), 4); + + let template_ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect(); + assert!(template_ids.contains(&"ecommerce-promo")); + assert!(template_ids.contains(&"brand-image")); + assert!(template_ids.contains(&"social-media")); + assert!(template_ids.contains(&"event-promo")); + } + + #[test] + fn test_cascade_delete() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let persona = create_test_persona(&conn, "project-1"); + + // 创建扩展 + let req = CreateBrandExtensionRequest { + persona_id: persona.id.clone(), + brand_tone: Some(BrandTone::default()), + design: None, + visual: None, + }; + BrandPersonaDao::create(&conn, &req).unwrap(); + + // 验证扩展存在 + assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_some()); + + // 删除人设 + PersonaDao::delete(&conn, &persona.id).unwrap(); + + // 验证扩展也被删除(级联删除) + assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_none()); + } +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 15beba111..7749b8475 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -1,5 +1,7 @@ +pub mod a2ui_form_dao; pub mod agent; pub mod api_key_provider; +pub mod brand_persona_dao; pub mod chat; pub mod general_chat; pub mod installed_plugins; @@ -7,6 +9,7 @@ pub mod material_dao; pub mod mcp; pub mod orchestrator; pub mod persona_dao; +pub mod poster_material_dao; pub mod prompts; pub mod provider_pool; pub mod providers; diff --git a/src-tauri/src/database/dao/poster_material_dao.rs b/src-tauri/src/database/dao/poster_material_dao.rs new file mode 100644 index 000000000..719ca5f39 --- /dev/null +++ b/src-tauri/src/database/dao/poster_material_dao.rs @@ -0,0 +1,724 @@ +//! 海报素材元数据数据访问层 +//! +//! 提供海报素材元数据(PosterMaterialMetadata)的 CRUD 操作,包括: +//! - 创建、获取、更新、删除海报素材元数据 +//! - 按分类筛选素材 + +use rusqlite::{params, Connection}; +use uuid::Uuid; + +use crate::errors::project_error::MaterialError; +use crate::models::project_model::{ + CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata, +}; + +use super::material_dao::MaterialDao; + +// ============================================================================ +// 数据访问对象 +// ============================================================================ + +/// 海报素材元数据 DAO +/// +/// 提供海报素材元数据的数据库操作方法。 +pub struct PosterMaterialDao; + +impl PosterMaterialDao { + // ------------------------------------------------------------------------ + // 创建元数据 + // ------------------------------------------------------------------------ + + /// 创建海报素材元数据 + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `req`: 创建请求 + /// + /// # 返回 + /// - 成功返回创建的元数据 + /// - 失败返回 MaterialError + pub fn create( + conn: &Connection, + req: &CreatePosterMetadataRequest, + ) -> Result { + // 验证素材存在 + MaterialDao::get(conn, &req.material_id)? + .ok_or_else(|| MaterialError::NotFound(req.material_id.clone()))?; + + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp(); + + // 序列化 colors + let colors_json = serde_json::to_string(&req.colors.clone().unwrap_or_default()) + .unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "INSERT INTO poster_material_metadata ( + id, material_id, image_category, width, height, thumbnail, + colors_json, icon_style, icon_category, color_scheme_json, + mood, layout_category, element_count, preview, fabric_json, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + params![ + id, + req.material_id, + req.image_category, + req.width, + req.height, + req.thumbnail, + colors_json, + req.icon_style, + req.icon_category, + req.color_scheme_json, + req.mood, + req.layout_category, + req.element_count, + req.preview, + req.fabric_json, + now, + now, + ], + )?; + + Ok(PosterMaterialMetadata { + material_id: req.material_id.clone(), + image_category: req.image_category.clone(), + width: req.width, + height: req.height, + thumbnail: req.thumbnail.clone(), + colors: req.colors.clone().unwrap_or_default(), + icon_style: req.icon_style.clone(), + icon_category: req.icon_category.clone(), + color_scheme_json: req.color_scheme_json.clone(), + mood: req.mood.clone(), + layout_category: req.layout_category.clone(), + element_count: req.element_count, + preview: req.preview.clone(), + fabric_json: req.fabric_json.clone(), + created_at: now, + updated_at: now, + }) + } + + // ------------------------------------------------------------------------ + // 获取元数据 + // ------------------------------------------------------------------------ + + /// 获取海报素材元数据 + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `material_id`: 素材 ID + /// + /// # 返回 + /// - 成功返回 Option + /// - 失败返回 MaterialError + pub fn get( + conn: &Connection, + material_id: &str, + ) -> Result, MaterialError> { + let mut stmt = conn.prepare( + "SELECT material_id, image_category, width, height, thumbnail, + colors_json, icon_style, icon_category, color_scheme_json, + mood, layout_category, element_count, preview, fabric_json, + created_at, updated_at + FROM poster_material_metadata WHERE material_id = ?", + )?; + + let mut rows = stmt.query([material_id])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::map_row(row)?)) + } else { + Ok(None) + } + } + + /// 获取完整的海报素材(基础素材 + 元数据) + /// + /// # 参数 + /// - `conn`: 数据库连接 + /// - `material_id`: 素材 ID + /// + /// # 返回 + /// - 成功返回 Option + /// - 失败返回 MaterialError + pub fn get_poster_material( + conn: &Connection, + material_id: &str, + ) -> Result, MaterialError> { + // 获取基础素材 + let base = match MaterialDao::get(conn, material_id)? { + Some(m) => m, + None => return Ok(None), + }; + + // 获取元数据 + let metadata = Self::get(conn, material_id)?; + + Ok(Some(PosterMaterial { base, metadata })) + } + + // ------------------------------------------------------------------------ + // 列表查询 + // ------------------------------------------------------------------------ + + /// 按图片分类获取素材列表 + pub fn list_by_image_category( + conn: &Connection, + project_id: &str, + category: Option<&str>, + ) -> Result, MaterialError> { + let sql = if category.is_some() { + "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, + m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, + pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail, + pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json, + pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, + pm.created_at as pm_created_at, pm.updated_at as pm_updated_at + FROM materials m + LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + WHERE m.project_id = ?1 AND m.material_type = 'image' AND pm.image_category = ?2 + ORDER BY m.created_at DESC" + } else { + "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, + m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, + pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail, + pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json, + pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, + pm.created_at as pm_created_at, pm.updated_at as pm_updated_at + FROM materials m + LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + WHERE m.project_id = ?1 AND m.material_type = 'image' + ORDER BY m.created_at DESC" + }; + + let mut stmt = conn.prepare(sql)?; + + let results: Vec = if let Some(cat) = category { + stmt.query_map(params![project_id, cat], |row| Self::map_joined_row(row))? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map([project_id], |row| Self::map_joined_row(row))? + .filter_map(|r| r.ok()) + .collect() + }; + + Ok(results) + } + + /// 按布局分类获取素材列表 + pub fn list_by_layout_category( + conn: &Connection, + project_id: &str, + category: Option<&str>, + ) -> Result, MaterialError> { + let sql = if category.is_some() { + "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, + m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, + pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail, + pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json, + pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, + pm.created_at as pm_created_at, pm.updated_at as pm_updated_at + FROM materials m + LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + WHERE m.project_id = ?1 AND m.material_type = 'layout' AND pm.layout_category = ?2 + ORDER BY m.created_at DESC" + } else { + "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, + m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, + pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail, + pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json, + pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, + pm.created_at as pm_created_at, pm.updated_at as pm_updated_at + FROM materials m + LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + WHERE m.project_id = ?1 AND m.material_type = 'layout' + ORDER BY m.created_at DESC" + }; + + let mut stmt = conn.prepare(sql)?; + + let results: Vec = if let Some(cat) = category { + stmt.query_map(params![project_id, cat], |row| Self::map_joined_row(row))? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map([project_id], |row| Self::map_joined_row(row))? + .filter_map(|r| r.ok()) + .collect() + }; + + Ok(results) + } + + /// 按配色氛围获取素材列表 + pub fn list_by_mood( + conn: &Connection, + project_id: &str, + mood: Option<&str>, + ) -> Result, MaterialError> { + let sql = if mood.is_some() { + "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, + m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, + pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail, + pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json, + pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, + pm.created_at as pm_created_at, pm.updated_at as pm_updated_at + FROM materials m + LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + WHERE m.project_id = ?1 AND m.material_type = 'color' AND pm.mood = ?2 + ORDER BY m.created_at DESC" + } else { + "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, + m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, + pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail, + pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json, + pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, + pm.created_at as pm_created_at, pm.updated_at as pm_updated_at + FROM materials m + LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + WHERE m.project_id = ?1 AND m.material_type = 'color' + ORDER BY m.created_at DESC" + }; + + let mut stmt = conn.prepare(sql)?; + + let results: Vec = if let Some(m) = mood { + stmt.query_map(params![project_id, m], |row| Self::map_joined_row(row))? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map([project_id], |row| Self::map_joined_row(row))? + .filter_map(|r| r.ok()) + .collect() + }; + + Ok(results) + } + + // ------------------------------------------------------------------------ + // 更新元数据 + // ------------------------------------------------------------------------ + + /// 更新海报素材元数据 + /// + /// 如果元数据不存在,则创建新的元数据。 + pub fn update( + conn: &Connection, + material_id: &str, + req: &CreatePosterMetadataRequest, + ) -> Result { + // 检查元数据是否存在 + let existing = Self::get(conn, material_id)?; + + if existing.is_none() { + // 创建新元数据 + return Self::create(conn, req); + } + + let existing = existing.unwrap(); + let now = chrono::Utc::now().timestamp(); + + // 构建更新后的值 + let image_category = req.image_category.clone().or(existing.image_category); + let width = req.width.or(existing.width); + let height = req.height.or(existing.height); + let thumbnail = req.thumbnail.clone().or(existing.thumbnail); + let colors = req.colors.clone().unwrap_or(existing.colors); + let icon_style = req.icon_style.clone().or(existing.icon_style); + let icon_category = req.icon_category.clone().or(existing.icon_category); + let color_scheme_json = req.color_scheme_json.clone().or(existing.color_scheme_json); + let mood = req.mood.clone().or(existing.mood); + let layout_category = req.layout_category.clone().or(existing.layout_category); + let element_count = req.element_count.or(existing.element_count); + let preview = req.preview.clone().or(existing.preview); + let fabric_json = req.fabric_json.clone().or(existing.fabric_json); + + let colors_json = serde_json::to_string(&colors).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "UPDATE poster_material_metadata SET + image_category = ?1, width = ?2, height = ?3, thumbnail = ?4, + colors_json = ?5, icon_style = ?6, icon_category = ?7, + color_scheme_json = ?8, mood = ?9, layout_category = ?10, + element_count = ?11, preview = ?12, fabric_json = ?13, updated_at = ?14 + WHERE material_id = ?15", + params![ + image_category, + width, + height, + thumbnail, + colors_json, + icon_style, + icon_category, + color_scheme_json, + mood, + layout_category, + element_count, + preview, + fabric_json, + now, + material_id, + ], + )?; + + Ok(PosterMaterialMetadata { + material_id: material_id.to_string(), + image_category, + width, + height, + thumbnail, + colors, + icon_style, + icon_category, + color_scheme_json, + mood, + layout_category, + element_count, + preview, + fabric_json, + created_at: existing.created_at, + updated_at: now, + }) + } + + // ------------------------------------------------------------------------ + // 删除元数据 + // ------------------------------------------------------------------------ + + /// 删除海报素材元数据 + pub fn delete(conn: &Connection, material_id: &str) -> Result<(), MaterialError> { + conn.execute( + "DELETE FROM poster_material_metadata WHERE material_id = ?", + [material_id], + )?; + Ok(()) + } + + // ------------------------------------------------------------------------ + // 辅助方法 + // ------------------------------------------------------------------------ + + /// 映射数据库行到 PosterMaterialMetadata 结构体 + fn map_row(row: &rusqlite::Row) -> Result { + let colors_json: String = row.get(5)?; + let colors: Vec = serde_json::from_str(&colors_json).unwrap_or_default(); + + Ok(PosterMaterialMetadata { + material_id: row.get(0)?, + image_category: row.get(1)?, + width: row.get(2)?, + height: row.get(3)?, + thumbnail: row.get(4)?, + colors, + icon_style: row.get(6)?, + icon_category: row.get(7)?, + color_scheme_json: row.get(8)?, + mood: row.get(9)?, + layout_category: row.get(10)?, + element_count: row.get(11)?, + preview: row.get(12)?, + fabric_json: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + }) + } + + /// 映射联合查询的数据库行到 PosterMaterial 结构体 + fn map_joined_row(row: &rusqlite::Row) -> Result { + use crate::models::project_model::Material; + + // 解析基础素材 + let tags_json: String = row.get(8)?; + let tags: Vec = serde_json::from_str(&tags_json).unwrap_or_default(); + + let base = Material { + id: row.get(0)?, + project_id: row.get(1)?, + name: row.get(2)?, + material_type: row.get(3)?, + file_path: row.get(4)?, + file_size: row.get(5)?, + mime_type: row.get(6)?, + content: row.get(7)?, + tags, + description: row.get(9)?, + created_at: row.get(10)?, + }; + + // 解析元数据(可能为空) + let metadata_material_id: Option = row.get(11)?; + let metadata = if metadata_material_id.is_some() { + 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(), + image_category: row.get(12)?, + width: row.get(13)?, + height: row.get(14)?, + thumbnail: row.get(15)?, + colors, + icon_style: row.get(17)?, + icon_category: row.get(18)?, + color_scheme_json: row.get(19)?, + mood: row.get(20)?, + layout_category: row.get(21)?, + element_count: row.get(22)?, + preview: row.get(23)?, + fabric_json: row.get(24)?, + created_at: row.get(25)?, + updated_at: row.get(26)?, + }) + } else { + None + }; + + Ok(PosterMaterial { base, metadata }) + } +} + +// ============================================================================ +// 测试 +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use crate::models::project_model::UploadMaterialRequest; + + /// 创建测试数据库连接 + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + conn + } + + /// 创建测试项目 + fn create_test_project(conn: &Connection, id: &str) { + let now = chrono::Utc::now().timestamp(); + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + id, + "测试项目", + "persistent", + format!("/test/{}", id), + now, + now + ], + ) + .unwrap(); + } + + /// 创建测试素材 + fn create_test_material( + conn: &Connection, + project_id: &str, + material_type: &str, + ) -> crate::models::project_model::Material { + let req = UploadMaterialRequest { + project_id: project_id.to_string(), + name: "测试素材".to_string(), + material_type: material_type.to_string(), + file_path: None, + content: Some("test content".to_string()), + tags: None, + description: None, + }; + MaterialDao::create(conn, &req).unwrap() + } + + #[test] + fn test_create_poster_metadata() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let material = create_test_material(&conn, "project-1", "image"); + + let req = CreatePosterMetadataRequest { + material_id: material.id.clone(), + image_category: Some("background".to_string()), + width: Some(1920), + height: Some(1080), + thumbnail: Some("thumb.jpg".to_string()), + colors: Some(vec!["#FF0000".to_string(), "#00FF00".to_string()]), + icon_style: None, + icon_category: None, + color_scheme_json: None, + mood: None, + layout_category: None, + element_count: None, + preview: None, + fabric_json: None, + }; + + let metadata = PosterMaterialDao::create(&conn, &req).unwrap(); + + assert_eq!(metadata.material_id, material.id); + assert_eq!(metadata.image_category, Some("background".to_string())); + assert_eq!(metadata.width, Some(1920)); + assert_eq!(metadata.height, Some(1080)); + assert_eq!(metadata.colors.len(), 2); + } + + #[test] + fn test_get_poster_metadata() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let material = create_test_material(&conn, "project-1", "image"); + + let req = CreatePosterMetadataRequest { + material_id: material.id.clone(), + image_category: Some("product".to_string()), + width: Some(800), + height: Some(600), + thumbnail: None, + colors: None, + icon_style: None, + icon_category: None, + color_scheme_json: None, + mood: None, + layout_category: None, + element_count: None, + preview: None, + fabric_json: None, + }; + + PosterMaterialDao::create(&conn, &req).unwrap(); + + let metadata = PosterMaterialDao::get(&conn, &material.id).unwrap(); + assert!(metadata.is_some()); + let metadata = metadata.unwrap(); + assert_eq!(metadata.image_category, Some("product".to_string())); + } + + #[test] + fn test_get_poster_material() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let material = create_test_material(&conn, "project-1", "image"); + + let req = CreatePosterMetadataRequest { + material_id: material.id.clone(), + image_category: Some("decoration".to_string()), + width: Some(500), + height: Some(500), + thumbnail: None, + colors: Some(vec!["#0000FF".to_string()]), + icon_style: None, + icon_category: None, + color_scheme_json: None, + mood: None, + layout_category: None, + element_count: None, + preview: None, + fabric_json: None, + }; + + PosterMaterialDao::create(&conn, &req).unwrap(); + + let poster_material = PosterMaterialDao::get_poster_material(&conn, &material.id).unwrap(); + assert!(poster_material.is_some()); + let poster_material = poster_material.unwrap(); + + assert_eq!(poster_material.base.id, material.id); + assert!(poster_material.metadata.is_some()); + assert_eq!( + poster_material.metadata.unwrap().image_category, + Some("decoration".to_string()) + ); + } + + #[test] + fn test_update_poster_metadata() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let material = create_test_material(&conn, "project-1", "image"); + + // 创建初始元数据 + let req = CreatePosterMetadataRequest { + material_id: material.id.clone(), + image_category: Some("background".to_string()), + width: Some(1920), + height: Some(1080), + thumbnail: None, + colors: None, + icon_style: None, + icon_category: None, + color_scheme_json: None, + mood: None, + layout_category: None, + element_count: None, + preview: None, + fabric_json: None, + }; + PosterMaterialDao::create(&conn, &req).unwrap(); + + // 更新元数据 + let update_req = CreatePosterMetadataRequest { + material_id: material.id.clone(), + image_category: Some("product".to_string()), + width: None, + height: None, + thumbnail: Some("new_thumb.jpg".to_string()), + colors: Some(vec!["#FFFFFF".to_string()]), + icon_style: None, + icon_category: None, + color_scheme_json: None, + mood: None, + layout_category: None, + element_count: None, + preview: None, + fabric_json: None, + }; + + let updated = PosterMaterialDao::update(&conn, &material.id, &update_req).unwrap(); + + assert_eq!(updated.image_category, Some("product".to_string())); + assert_eq!(updated.width, Some(1920)); // 保留原值 + assert_eq!(updated.thumbnail, Some("new_thumb.jpg".to_string())); + assert_eq!(updated.colors, vec!["#FFFFFF".to_string()]); + } + + #[test] + fn test_delete_poster_metadata() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1"); + let material = create_test_material(&conn, "project-1", "image"); + + let req = CreatePosterMetadataRequest { + material_id: material.id.clone(), + image_category: Some("texture".to_string()), + width: None, + height: None, + thumbnail: None, + colors: None, + icon_style: None, + icon_category: None, + color_scheme_json: None, + mood: None, + layout_category: None, + element_count: None, + preview: None, + fabric_json: None, + }; + PosterMaterialDao::create(&conn, &req).unwrap(); + + // 验证存在 + assert!(PosterMaterialDao::get(&conn, &material.id) + .unwrap() + .is_some()); + + // 删除 + PosterMaterialDao::delete(&conn, &material.id).unwrap(); + + // 验证已删除 + assert!(PosterMaterialDao::get(&conn, &material.id) + .unwrap() + .is_none()); + } +} diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 1895456dc..ede5f27f7 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -870,6 +870,111 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // ============================================================================ + // A2UI 表单数据表 + // 存储 AI 生成的交互式表单及用户填写的数据 + // ============================================================================ + conn.execute( + "CREATE TABLE IF NOT EXISTS a2ui_forms ( + id TEXT PRIMARY KEY, + message_id INTEGER NOT NULL, + session_id TEXT NOT NULL, + a2ui_response_json TEXT NOT NULL, + form_data_json TEXT DEFAULT '{}', + submitted INTEGER DEFAULT 0, + submitted_at TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (message_id) REFERENCES agent_messages(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE + )", + [], + )?; + + // 创建 a2ui_forms 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_a2ui_forms_message ON a2ui_forms(message_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id)", + [], + )?; + + // ============================================================================ + // 品牌人设扩展表 (BrandPersonaExtension) + // 存储品牌人设的海报设计专用字段,与 personas 表关联 + // ============================================================================ + conn.execute( + "CREATE TABLE IF NOT EXISTS brand_persona_extensions ( + id TEXT PRIMARY KEY, + persona_id TEXT NOT NULL UNIQUE, + brand_tone_json TEXT NOT NULL DEFAULT '{}', + design_json TEXT NOT NULL DEFAULT '{}', + visual_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (persona_id) REFERENCES personas(id) ON DELETE CASCADE + )", + [], + )?; + + // 创建 brand_persona_extensions 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_brand_persona_extensions_persona_id ON brand_persona_extensions(persona_id)", + [], + )?; + + // ============================================================================ + // 海报素材元数据表 (PosterMaterialMetadata) + // 存储海报素材的扩展信息,与 materials 表关联 + // ============================================================================ + conn.execute( + "CREATE TABLE IF NOT EXISTS poster_material_metadata ( + id TEXT PRIMARY KEY, + material_id TEXT NOT NULL UNIQUE, + image_category TEXT, + width INTEGER, + height INTEGER, + thumbnail TEXT, + colors_json TEXT NOT NULL DEFAULT '[]', + icon_style TEXT, + icon_category TEXT, + color_scheme_json TEXT, + mood TEXT, + layout_category TEXT, + element_count INTEGER, + preview TEXT, + fabric_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE + )", + [], + )?; + + // 创建 poster_material_metadata 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_material_id ON poster_material_metadata(material_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_image_category ON poster_material_metadata(image_category)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_icon_category ON poster_material_metadata(icon_category)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_layout_category ON poster_material_metadata(layout_category)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_mood ON poster_material_metadata(mood)", + [], + )?; + Ok(()) } diff --git a/src-tauri/src/models/project_model.rs b/src-tauri/src/models/project_model.rs index 316eb31f1..6a42ab7a4 100644 --- a/src-tauri/src/models/project_model.rs +++ b/src-tauri/src/models/project_model.rs @@ -61,6 +61,7 @@ pub struct Persona { /// 创建人设请求 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CreatePersonaRequest { /// 所属项目 ID pub project_id: String, @@ -163,6 +164,12 @@ pub enum MaterialType { Data, /// 链接 Link, + /// 图标(海报扩展) + Icon, + /// 配色方案(海报扩展) + Color, + /// 布局模板(海报扩展) + Layout, } impl Default for MaterialType { @@ -179,6 +186,9 @@ impl MaterialType { MaterialType::Text => "text", MaterialType::Data => "data", MaterialType::Link => "link", + MaterialType::Icon => "icon", + MaterialType::Color => "color", + MaterialType::Layout => "layout", } } @@ -189,9 +199,253 @@ impl MaterialType { "text" => MaterialType::Text, "data" => MaterialType::Data, "link" => MaterialType::Link, + "icon" => MaterialType::Icon, + "color" => MaterialType::Color, + "layout" => MaterialType::Layout, _ => MaterialType::Document, } } + + /// 判断是否为海报素材类型 + pub fn is_poster_material(&self) -> bool { + matches!( + self, + MaterialType::Image | MaterialType::Icon | MaterialType::Color | MaterialType::Layout + ) + } +} + +/// 图片分类 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageCategory { + /// 背景图 + Background, + /// 产品图 + Product, + /// 人物图 + Person, + /// 装饰图 + Decoration, + /// 纹理图 + Texture, + /// 其他 + Other, +} + +impl Default for ImageCategory { + fn default() -> Self { + Self::Other + } +} + +impl ImageCategory { + pub fn as_str(&self) -> &'static str { + match self { + ImageCategory::Background => "background", + ImageCategory::Product => "product", + ImageCategory::Person => "person", + ImageCategory::Decoration => "decoration", + ImageCategory::Texture => "texture", + ImageCategory::Other => "other", + } + } + + 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 => "背景", + ImageCategory::Product => "产品", + ImageCategory::Person => "人物", + ImageCategory::Decoration => "装饰", + ImageCategory::Texture => "纹理", + ImageCategory::Other => "其他", + } + } +} + +/// 布局分类 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LayoutCategory { + /// 大图型 + HeroImage, + /// 文字主导 + TextDominant, + /// 网格型 + Grid, + /// 分割型 + Split, + /// 极简型 + Minimal, + /// 拼贴型 + Collage, +} + +impl Default for LayoutCategory { + fn default() -> Self { + Self::HeroImage + } +} + +impl LayoutCategory { + pub fn as_str(&self) -> &'static str { + match self { + LayoutCategory::HeroImage => "hero-image", + LayoutCategory::TextDominant => "text-dominant", + LayoutCategory::Grid => "grid", + LayoutCategory::Split => "split", + LayoutCategory::Minimal => "minimal", + LayoutCategory::Collage => "collage", + } + } + + 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 => "大图型", + LayoutCategory::TextDominant => "文字型", + LayoutCategory::Grid => "网格型", + LayoutCategory::Split => "分割型", + LayoutCategory::Minimal => "极简型", + LayoutCategory::Collage => "拼贴型", + } + } +} + +/// 海报素材元数据 +/// +/// 存储海报素材的扩展信息,与 materials 表关联。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PosterMaterialMetadata { + /// 关联的素材 ID + pub material_id: String, + /// 图片分类(仅 image 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub image_category: Option, + /// 图片宽度 + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + /// 图片高度 + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + /// 缩略图路径或 base64 + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, + /// 主色列表(JSON 数组) + #[serde(default)] + pub colors: Vec, + /// 图标风格(仅 icon 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_style: Option, + /// 图标分类(仅 icon 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_category: Option, + /// 配色方案数据(仅 color 类型,JSON) + #[serde(skip_serializing_if = "Option::is_none")] + pub color_scheme_json: Option, + /// 配色氛围(仅 color 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub mood: Option, + /// 布局分类(仅 layout 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_category: Option, + /// 布局元素数量(仅 layout 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub element_count: Option, + /// 布局预览图 + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + /// Fabric.js JSON(仅 layout 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub fabric_json: Option, + /// 创建时间 + pub created_at: i64, + /// 更新时间 + pub updated_at: i64, +} + +/// 创建海报素材元数据请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePosterMetadataRequest { + /// 关联的素材 ID + pub material_id: String, + /// 图片分类 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_category: Option, + /// 图片宽度 + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + /// 图片高度 + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + /// 缩略图 + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, + /// 主色列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub colors: Option>, + /// 图标风格 + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_style: Option, + /// 图标分类 + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_category: Option, + /// 配色方案 JSON + #[serde(skip_serializing_if = "Option::is_none")] + pub color_scheme_json: Option, + /// 配色氛围 + #[serde(skip_serializing_if = "Option::is_none")] + pub mood: Option, + /// 布局分类 + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_category: Option, + /// 布局元素数量 + #[serde(skip_serializing_if = "Option::is_none")] + pub element_count: Option, + /// 布局预览图 + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + /// Fabric.js JSON + #[serde(skip_serializing_if = "Option::is_none")] + pub fabric_json: Option, +} + +/// 海报素材(完整视图) +/// +/// 包含基础素材和海报扩展元数据的完整数据。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PosterMaterial { + /// 基础素材 + #[serde(flatten)] + pub base: Material, + /// 海报元数据 + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, } /// 素材 @@ -233,6 +487,7 @@ pub struct Material { /// 上传素材请求 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct UploadMaterialRequest { /// 所属项目 ID pub project_id: String, @@ -271,6 +526,7 @@ pub struct MaterialUpdate { /// 素材筛选条件 #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct MaterialFilter { /// 按类型筛选 #[serde(rename = "type", skip_serializing_if = "Option::is_none")] @@ -427,6 +683,7 @@ pub struct Template { /// 创建模板请求 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CreateTemplateRequest { /// 所属项目 ID pub project_id: String, @@ -533,6 +790,466 @@ pub struct ProjectContext { pub template: Option