From 54611cd3ab57dc41dbcb234d82ecc90c1646c91c Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 24 Feb 2026 16:18:57 +0800 Subject: [PATCH] feat: release v0.71.0 with full pending changes --- FIX_SUMMARY.md | 203 --- IMPLEMENTATION_PLAN.md | 31 - README.md | 45 +- WINDOWS_CRASH_ANALYSIS.md | 229 ---- WINDOWS_TEST_GUIDE.md | 159 --- package.json | 9 +- scripts/ensure-dev-port.mjs | 124 ++ src-tauri/Cargo.lock | 30 +- src-tauri/Cargo.toml | 4 +- .../crates/agent/src/aster_state_support.rs | 1 + src-tauri/crates/agent/src/event_converter.rs | 4 + src-tauri/crates/core/src/config/types.rs | 3 + src-tauri/crates/core/src/database/dao/mod.rs | 1 + .../database/dao/video_generation_task_dao.rs | 244 ++++ src-tauri/crates/core/src/database/schema.rs | 39 + src-tauri/crates/core/src/workspace/types.rs | 12 - src-tauri/crates/services/src/lib.rs | 1 + .../services/src/video_generation_service.rs | 977 +++++++++++++++ src-tauri/install-local.sh | 70 ++ src-tauri/src/app/runner.rs | 5 + src-tauri/src/commands/mod.rs | 1 + .../src/commands/video_generation_cmd.rs | 90 ++ src/components/AppSidebar.tsx | 54 +- .../agent/chat/components/EmptyState.tsx | 220 ++-- .../chat/components/MarkdownRenderer.tsx | 72 +- .../chat/hooks/useAsterAgentChat.test.tsx | 20 + .../agent/chat/hooks/useAsterAgentChat.ts | 9 +- src/components/agent/chat/index.test.tsx | 92 +- src/components/agent/chat/index.tsx | 37 + .../utils/contextualRecommendations.test.ts | 148 +++ .../chat/utils/contextualRecommendations.ts | 338 +++++ .../agent/chat/utils/defaultGuidePrompt.ts | 25 + src/components/artifact/canvasAdapterUtils.ts | 6 + src/components/chat/ChatPage.tsx | 27 +- src/components/chat/components/EmptyState.tsx | 159 ++- .../content-creator/canvas/CanvasFactory.tsx | 26 +- .../content-creator/canvas/canvasUtils.ts | 11 +- .../canvas/document/DocumentCanvas.tsx | 16 +- .../canvas/document/DocumentRenderer.tsx | 53 +- .../canvas/document/editor/NotionEditor.tsx | 32 +- .../content-creator/canvas/document/types.ts | 6 + .../canvas/novel/NovelCanvas.tsx | 7 + .../canvas/video/PromptInput.tsx | 117 ++ .../canvas/video/VideoCanvas.tsx | 377 ++++++ .../canvas/video/VideoSidebar.tsx | 1085 +++++++++++++++++ .../canvas/video/VideoWorkspace.tsx | 645 ++++++++++ .../content-creator/canvas/video/index.ts | 2 + .../content-creator/canvas/video/types.ts | 52 + src/components/settings-v2/_layout/index.tsx | 9 +- .../settings-v2/general/appearance/index.tsx | 242 ++++ .../general/chat-appearance/index.tsx | 629 +++++----- src/components/settings/GeneralSettings.tsx | 16 +- src/components/workspace/WorkbenchPage.tsx | 212 ++-- src/hooks/useTauri.ts | 2 + src/lib/api/videoGeneration.ts | 87 ++ src/lib/tauri-mock/core.ts | 33 +- sync-resources.sh | 15 - test-crash-fix.md | 112 -- test-messaging.sh | 48 - 59 files changed, 5786 insertions(+), 1537 deletions(-) delete mode 100644 FIX_SUMMARY.md delete mode 100644 IMPLEMENTATION_PLAN.md delete mode 100644 WINDOWS_CRASH_ANALYSIS.md delete mode 100644 WINDOWS_TEST_GUIDE.md create mode 100644 scripts/ensure-dev-port.mjs create mode 100644 src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs create mode 100644 src-tauri/crates/services/src/video_generation_service.rs create mode 100755 src-tauri/install-local.sh create mode 100644 src-tauri/src/commands/video_generation_cmd.rs create mode 100644 src/components/agent/chat/utils/contextualRecommendations.test.ts create mode 100644 src/components/agent/chat/utils/contextualRecommendations.ts create mode 100644 src/components/agent/chat/utils/defaultGuidePrompt.ts create mode 100644 src/components/content-creator/canvas/video/PromptInput.tsx create mode 100644 src/components/content-creator/canvas/video/VideoCanvas.tsx create mode 100644 src/components/content-creator/canvas/video/VideoSidebar.tsx create mode 100644 src/components/content-creator/canvas/video/VideoWorkspace.tsx create mode 100644 src/components/content-creator/canvas/video/index.ts create mode 100644 src/components/content-creator/canvas/video/types.ts create mode 100644 src/components/settings-v2/general/appearance/index.tsx create mode 100644 src/lib/api/videoGeneration.ts delete mode 100755 sync-resources.sh delete mode 100644 test-crash-fix.md delete mode 100755 test-messaging.sh diff --git a/FIX_SUMMARY.md b/FIX_SUMMARY.md deleted file mode 100644 index 67cffe993..000000000 --- a/FIX_SUMMARY.md +++ /dev/null @@ -1,203 +0,0 @@ -# Windows 闪退问题修复总结 - -## 问题概述 -用户报告 ProxyCast v0.70 在 Windows 11 上发送第一条消息时崩溃,而 macOS 开发环境正常工作。 - -## 根本原因分析 - -### 1. 平台差异 -通过 Context7 MCP 分析发现的关键差异: - -| 平台 | 渲染引擎 | I/O 模型 | 特点 | -|------|----------|----------|------| -| Windows | Chromium | IOCP | 更严格的资源限制 | -| macOS | WebKit | kqueue | POSIX 风格的文件锁 | -| Linux | WebKit | epoll/io-uring | 灵活的线程池 | - -### 2. Tokio Runtime 创建问题 -原来的代码: -```rust -tokio::runtime::Runtime::new().unwrap() -``` - -**问题**: -- `Runtime::new()` 在不同平台上有不同的默认行为 -- Windows 上线程池创建可能失败 -- IOCP 初始化可能因资源不足失败 - -### 3. 版本检查 -✅ **aster-rust v0.13.0** - 已是最新版本,无需更新 - -## 修复方案 - -### 修复 1: 改进 Tokio Runtime 创建 -**文件**: `src-tauri/src/app/bootstrap.rs:147` - -```rust -// 修改前 -tokio::runtime::Runtime::new() - .expect("Failed to create tokio runtime...") - .handle() - .clone() - -// 修改后 -tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) // 限制线程数,避免 Windows 资源问题 - .thread_name("proxycast-runtime") - .enable_io() - .enable_time() - .build() - .expect("Failed to create tokio runtime: 系统资源不足或配置错误") - .handle() - .clone() -``` - -**优势**: -- 使用 Builder 模式获得更多控制 -- 限制工作线程数,避免 Windows 资源问题 -- 添加平台特定的日志输出 -- 提高错误信息的可读性 - -### 修复 2: 添加 Windows 数据库验证 -```rust -#[cfg(target_os = "windows")] -{ - tracing::info!("[Bootstrap] Windows 平台 - 验证数据库文件权限"); - match db.lock() { - Ok(conn) => { - if let Err(e) = conn.execute("PRAGMA user_version", []) { - tracing::warn!("[Bootstrap] Windows 数据库验证失败: {}", e); - } else { - tracing::info!("[Bootstrap] Windows 数据库验证成功"); - } - } - Err(e) => { - tracing::warn!("[Bootstrap] Windows 数据库锁获取失败: {}", e); - } - } -} -``` - -### 修复 3: 前端错误处理 -**文件**: `src/components/agent/chat/index.tsx` - -```typescript -try { - await sendMessage(text, images || [], webSearch, thinking, false, sendExecutionStrategy); -} catch (error) { - console.error("[AgentChat] 发送消息失败:", error); - toast.error(`发送失败: ${error instanceof Error ? error.message : String(error)}`); - setInput(sourceText); // 恢复输入内容 -} -``` - -## 提交记录 - -### 提交 1: 0f6044d6 -``` -fix: 修复发送第一条消息时的闪退问题 - -- 移除危险的 unwrap() 调用 -- 添加前端错误处理 -- 验证模型过滤逻辑 -- 验证加密模块 -``` - -### 提交 2: 0a1243e6 -``` -feat: 改进 Windows 平台兼容性 - -- 使用 Builder 模式创建 Tokio Runtime -- 限制工作线程数为 2 -- 添加 Windows 数据库验证 -- 添加平台特定的日志输出 -``` - -## 文档 - -创建了完整的文档体系: - -1. **WINDOWS_CRASH_ANALYSIS.md** - - 平台差异详细分析 - - Context7 MCP 文档引用 - - 风险点识别 - - 修复建议 - -2. **WINDOWS_TEST_GUIDE.md** - - Windows 11 测试步骤 - - 常见问题排查 - - 日志收集方法 - - 性能对比 - -3. **test-crash-fix.md** - - 修复验证清单 - - 测试步骤 - - 预期结果 - -4. **test-messaging.sh** - - 自动化测试脚本 - -## 验证步骤 - -### 用户验证 -1. 拉取最新代码 -2. 在 Windows 11 上启动应用 -3. 发送第一条消息 -4. 查看日志输出 - -### 预期日志 -``` -[INFO] [Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP) -[INFO] [Bootstrap] Windows 平台 - 验证数据库文件权限 -[INFO] [Bootstrap] Windows 数据库验证成功 -[INFO] [AsterAgent] 发送流式消息: session=xxx, event=xxx -``` - -### 如果仍然崩溃 -收集以下信息: -1. 启用详细日志:`$env:RUST_LOG=trace` -2. 检查事件查看器 -3. 提供完整堆栈跟踪 -4. 系统信息:`systeminfo` - -## 技术亮点 - -### Context7 MCP 使用 -成功使用 Context7 MCP 查询: -- Tauri 平台差异文档 -- Tokio Runtime 跨平台兼容性 -- Rust 平台特定代码模式 - -### 跨平台最佳实践 -- 使用条件编译 `#[cfg(target_os = "windows")]` -- 使用 Builder 模式获得更多控制 -- 添加平台特定的验证逻辑 -- 提供详细的错误上下文 - -## 下一步 - -1. **在 Windows 11 上测试** - - 验证启动流程 - - 验证消息发送 - - 收集性能数据 - -2. **添加 CI/CD** - - Windows 构建管道 - - 自动化测试 - - 性能基准测试 - -3. **持续改进** - - 监控 Windows 特定问题 - - 优化线程池配置 - - 改进错误处理 - -## 参考资料 - -- [Tauri Windows 文档](https://tauri.app/v1/guides/building/windows) -- [Tokio Runtime 文档](https://tokio.rs/tokio/topics/runtime) -- [Rust Windows 平台支持](https://doc.rust-lang.org/rustc/platform-support/windows-pc-gnu-msvc.html) -- [Context7 MCP](https://context7.com) - -## 致谢 - -感谢用户反馈,帮助我们发现并修复这个跨平台兼容性问题。 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 782c34b04..000000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,31 +0,0 @@ -# ZeroClaw → ProxyCast/Aster-Rust 借鉴计划 - 实施状态 - -## 阶段 1:快速胜利 ✅ 完成 - -| # | 任务 | 层 | 状态 | 文件 | -|---|------|-----|------|------| -| 1-A | 错误分类和智能重试 | Aster | ✅ | `core/retry_logic.rs` | -| 1-B | 统一 Observer Trait | Aster | ✅ | `observability/` | -| 1-C | 请求体大小和超时限制 | ProxyCast | ✅ | `server/middleware/security.rs` | -| 1-D | 滑动窗口速率限制 | ProxyCast | ✅ | `server/middleware/rate_limit.rs` | -| 1-E | 凭证清理 | ProxyCast | ✅ | `core/sanitizer.rs` | -| 1-F | 历史修剪策略 | ProxyCast | ✅ | `processor/conversation_manager.rs` | - -## 阶段 2:核心增强 ✅ 完成 - -| # | 任务 | 层 | 状态 | 文件 | -|---|------|-----|------|------| -| 2-A | 组件监督者模式 | Aster | ✅ | `core/supervisor.rs` | -| 2-B | HeartbeatEngine | Aster | ✅ | `heartbeat/` | -| 2-C | SecurityPolicy Trait | Aster | ✅ | `security/policy.rs` | -| 2-D | 配对认证系统 | ProxyCast | ✅ | `server/auth/pairing.rs` | -| 2-E | 幂等性中间件 | ProxyCast | ✅ | `server/middleware/idempotency.rs` | -| 2-F | 提示路由系统 | ProxyCast | ✅ | `core/router/hint_router.rs` | - -## 阶段 3:高级功能 ✅ 完成 - -| # | 任务 | 层 | 状态 | 文件 | -|---|------|-----|------|------| -| 3-A | ChaCha20-Poly1305 加密 | ProxyCast | ✅ | `credential/encryption.rs` | -| 3-B | 对话摘要功能 | ProxyCast | ✅ | `processor/conversation_summarizer.rs` | -| 3-C | 配置热重载增强 | Aster | ✅ | `config/watcher.rs` | diff --git a/README.md b/README.md index cfa8def8b..9125c3ee7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ ProxyCast 是面向普通创作者的 AI Agent 平台。 你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成: + - 和 Agent 对话定方向 - 生成内容与素材 - 继续迭代修改 @@ -40,43 +41,51 @@ ProxyCast 是面向普通创作者的 AI Agent 平台。 ## 📖 创作场景(不止一种) ### 场景 1:社媒日更 -- 场景:每天都要稳定发内容,但选题和表达容易重复。 -- 动作:先让 Agent 给出 3 个方向,再选一个生成多版文案与配图思路。 + +- 场景:每天都要稳定发内容,但选题和表达容易重复。 +- 动作:先让 Agent 给出 3 个方向,再选一个生成多版文案与配图思路。 - 结果:当天可直接发布,同时保留素材供后续复用。 ### 场景 2:短视频起号 -- 场景:有想法但脚本总是“有点散”。 -- 动作:用主题工作流先拆结构,再生成口播稿和镜头节奏。 + +- 场景:有想法但脚本总是“有点散”。 +- 动作:用主题工作流先拆结构,再生成口播稿和镜头节奏。 - 结果:从模糊创意变成可拍摄脚本,沟通成本显著降低。 ### 场景 3:小说连载 -- 场景:长期连载容易设定冲突、节奏断档。 -- 动作:在同一项目里持续积累世界观、人物设定和章节草稿。 + +- 场景:长期连载容易设定冲突、节奏断档。 +- 动作:在同一项目里持续积累世界观、人物设定和章节草稿。 - 结果:剧情连贯性更强,更新更稳定。 ### 场景 4:活动海报与图文 -- 场景:活动上线前要快速产出多套视觉方向。 -- 动作:先生成文案方向,再出图并按参考图持续迭代。 + +- 场景:活动上线前要快速产出多套视觉方向。 +- 动作:先生成文案方向,再出图并按参考图持续迭代。 - 结果:方案选择更快,历史版本可追溯、可复用。 ### 场景 5:歌词创作 -- 场景:有旋律或主题,但歌词总卡在中段。 -- 动作:让 Agent 先给主副歌框架,再逐段续写与改写。 + +- 场景:有旋律或主题,但歌词总卡在中段。 +- 动作:让 Agent 先给主副歌框架,再逐段续写与改写。 - 结果:成稿速度更快,风格更统一。 ### 场景 6:知识内容输出 -- 场景:学了很多但难以整理成可分享内容。 -- 动作:把资料整理成结构化要点,再输出为卡片或长文。 + +- 场景:学了很多但难以整理成可分享内容。 +- 动作:把资料整理成结构化要点,再输出为卡片或长文。 - 结果:输入和输出形成闭环,知识更容易长期积累。 ### 场景 7:计划执行 -- 场景:目标很大,但每天不知道先做什么。 -- 动作:把目标拆成周计划与日任务,并按进度复盘调整。 + +- 场景:目标很大,但每天不知道先做什么。 +- 动作:把目标拆成周计划与日任务,并按进度复盘调整。 - 结果:执行路径清晰,可持续推进。 ### 场景 8:办公写作 -- 场景:报告、邮件、方案反复改,耗时高。 -- 动作:先生成初稿,再按受众快速改成不同版本。 + +- 场景:报告、邮件、方案反复改,耗时高。 +- 动作:先生成初稿,再按受众快速改成不同版本。 - 结果:沟通更顺,交付更快。 --- @@ -127,6 +136,7 @@ brew install --cask proxycast ## 📚 文档与开发(可选) 如果你是开发者,可查看: + - 项目文档:`docs/aiprompts/` - Agent 指南:`AGENTS.md` @@ -138,7 +148,8 @@ npm run tauri:dev npm run tauri build ``` -说明:开发脚本统一使用 `CARGO_TARGET_DIR=src-tauri/target`,避免生成分散的 `target_*` 目录。 +说明:开发脚本统一使用 `CARGO_TARGET_DIR=target`(在 `src-tauri/` 下),避免生成分散的 `target_*` 目录。 +请务必在仓库根目录执行上述命令;若在 `src-tauri/` 子目录执行,会误生成 `src-tauri/src-tauri/target`。 --- diff --git a/WINDOWS_CRASH_ANALYSIS.md b/WINDOWS_CRASH_ANALYSIS.md deleted file mode 100644 index f480d4a05..000000000 --- a/WINDOWS_CRASH_ANALYSIS.md +++ /dev/null @@ -1,229 +0,0 @@ -# Windows vs macOS 平台差异分析报告 - -## 问题背景 -用户报告在 Windows 11 上发送第一条消息时崩溃,而 macOS 开发环境正常工作。 - -## Context7 MCP 文档分析结果 - -### 1. Tauri 平台差异 - -**渲染引擎差异**: -- **Windows**: 使用 Chromium -- **macOS/Linux**: 使用 WebKit - -**重要发现**: Tauri 文档明确指出需要根据平台设置不同的构建目标: -```javascript -// Windows -chrome105 // 用于 Windows (Chromium) - -// macOS/Linux -safari13 // 用于 macOS 和 Linux (WebKit) -``` - -### 2. Tokio Runtime 平台差异 - -**关键问题**: `Runtime::new()` 在不同平台上的行为可能不同 - -从 Context7 文档中发现: -- Tokio 在不同平台上使用不同的 I/O 驱动 -- Linux 使用 `io-uring`(可选) -- macOS 使用 `kqueue` -- Windows 使用 `IOCP` (I/O Completion Ports) - -**Windows 特定风险**: -```rust -// 我们的代码 (bootstrap.rs:147) -let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| { - tokio::runtime::Runtime::new() - .expect("Failed to create tokio runtime: 系统资源不足或配置错误") - .handle() - .clone() -}); -``` - -**潜在问题**: -1. Windows 上的线程池创建可能更严格 -2. Windows 上的 IOCP 初始化可能失败 -3. Windows 上的栈大小默认值不同 - -### 3. Rust 平台特定代码 - -**条件编译示例**: -```rust -#[cfg(target_os = "windows")] -pub struct WindowsToken; - -#[cfg(target_os = "macos")] -pub struct MacosToken; -``` - -**我们的代码检查结果**: -- ✅ 已正确使用 `#[cfg(target_os = "windows")]` 进行平台特定代码隔离 -- ✅ 配置文件路径处理已正确处理 Windows 路径 - -## aster-rust 版本分析 - -### 当前使用的版本 -```toml -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" } -``` - -### 版本历史 -- **v0.13.0** (2025-02-18): ✅ 当前使用 - 最新版本 - - Commit: `4422f761` - - 包含修复: "fix clippy warnings, fmt, bump version" - -- **v0.12.0** (2025-02-16): 上一版本 - - 主要更新: "feat: add observability, supervisor, heartbeat" - -**结论**: ✅ **aster-rust 版本是最新的,不需要更新** - -## Windows 特定崩溃点分析 - -### 高风险点 - -#### 1. Tokio Runtime 创建 (bootstrap.rs:147) -```rust -tokio::runtime::Runtime::new() - .expect("Failed to create tokio runtime: 系统资源不足或配置错误") -``` - -**Windows 风险**: -- 线程池创建可能失败 -- IOCP 端口创建可能失败 -- 栈内存分配可能更严格 - -**建议修复**: -```rust -tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) // 限制线程数 - .thread_name("proxycast-runtime") - .enable_io() - .enable_time() - .build() - .expect("Failed to create tokio runtime") -``` - -#### 2. 数据库连接 (可能的问题) -```rust -let db = database::init_database() - .map_err(|e| format!("数据库初始化失败: {e}"))?; -``` - -**Windows 风险**: -- SQLite 在 Windows 上的文件锁行为不同 -- 路径长度限制 (MAX_PATH = 260 字符) -- 权限问题更严格 - -#### 3. 文件系统操作 -**Windows 特定限制**: -- 路径分隔符: `\` vs `/` -- 文件名大小写不敏感 -- 路径长度限制 -- 文件锁更严格 - -### 中风险点 - -#### 4. 加密模块初始化 -虽然加密模块只在测试中使用,但 Windows 上的加密 API 可能不同。 - -#### 5. MCP 服务器启动 -Windows 上的进程创建和 socket 行为可能不同。 - -## 建议的修复方案 - -### 立即修复 - -#### 1. 改进 Tokio Runtime 创建 -```rust -// bootstrap.rs:147 -let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| { - // 使用 Builder 模式获得更多控制 - tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .thread_name_fn(|| { - static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); - let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); - format!("proxycast-runtime-{}", id) - }) - .enable_io() - .enable_time() - .build() - .expect("Failed to create tokio runtime: please check system resources and permissions") - .handle() - .clone() -}); -``` - -#### 2. 添加 Windows 特定日志 -```rust -#[cfg(target_os = "windows")] -tracing::info!("[Bootstrap] Windows 平台 - 检查 IOCP 和线程池配置"); - -#[cfg(target_os = "macos")] -tracing::info!("[Bootstrap] macOS 平台 - 检查 kqueue 配置"); -``` - -#### 3. 添加数据库初始化重试 -```rust -let db = database::init_database() - .map_err(|e| format!("数据库初始化失败: {e}"))?; - -// Windows 特定:验证数据库可写性 -#[cfg(target_os = "windows")] -{ - use crate::database::dao; - let conn = db.lock().unwrap(); - if let Err(e) = dao::test_connection(&conn) { - tracing::error!("[Bootstrap] Windows 数据库连接测试失败: {}", e); - } -} -``` - -### 长期改进 - -1. **添加平台特定的集成测试** -2. **在 CI/CD 中添加 Windows 构建** -3. **添加 Windows 事件查看器日志支持** -4. **添加更详细的错误上下文** - -## 测试清单 - -### Windows 特定测试 -- [ ] 在 Windows 11 上启动应用 -- [ ] 检查事件查看器 (Event Viewer) 中的应用日志 -- [ ] 验证数据库文件创建位置 -- [ ] 测试长路径支持 -- [ ] 测试中文字符路径 -- [ ] 验证防火墙权限 - -### 建议的 Windows 调试命令 -```powershell -# 启用详细日志 -$env:RUST_LOG=debug -$env:RUST_BACKTRACE=1 -.\proxycast.exe - -# 检查事件日志 -Get-EventLog -LogName Application -Source "ProxyCast" -Newest 50 -``` - -## 结论 - -### 主要发现 -1. ✅ **aster-rust 版本是最新的** - 不需要更新 -2. ⚠️ **Tokio Runtime 创建可能在 Windows 上失败** - 需要改进 -3. ⚠️ **缺少 Windows 特定的错误处理** - 需要添加 -4. ⚠️ **Windows 平台测试不足** - 需要加强 - -### 下一步行动 -1. 实施上述建议的修复方案 -2. 在 Windows 11 上测试 -3. 添加 Windows CI/CD -4. 收集 Windows 用户的详细错误日志 - -## 参考资源 -- [Tauri Windows 文档](https://tauri.app/v1/guides/building/windows) -- [Tokio Runtime 文档](https://tokio.rs/tokio/topics/runtime) -- [Rust Windows 平台支持](https://doc.rust-lang.org/rustc/platform-support/windows-pc-gnu-msvc.html) diff --git a/WINDOWS_TEST_GUIDE.md b/WINDOWS_TEST_GUIDE.md deleted file mode 100644 index c086e9103..000000000 --- a/WINDOWS_TEST_GUIDE.md +++ /dev/null @@ -1,159 +0,0 @@ -# Windows 11 测试指南 - -## 修复说明 - -本次修复针对 Windows 平台的兼容性问题进行了以下改进: - -### 1. 改进 Tokio Runtime 创建 -- 使用 `Builder` 模式替代 `Runtime::new()` -- 限制工作线程数为 2(避免 Windows 资源问题) -- 添加平台特定的日志输出 -- 提高跨平台兼容性 - -### 2. 添加 Windows 数据库验证 -- 在启动时验证数据库文件权限 -- 添加 Windows 特定的诊断日志 - -## Windows 11 测试步骤 - -### 准备工作 - -1. **安装最新代码** - ```powershell - git pull origin main - git log --oneline -1 - # 应该看到: fix: 改进 Windows 平台兼容性 - ``` - -2. **启用详细日志** - ```powershell - # 设置环境变量 - $env:RUST_LOG=debug - $env:RUST_BACKTRACE=1 - - # 或者永久设置(管理员权限) - [System.Environment]::SetEnvironmentVariable("RUST_LOG", "debug", "User") - [System.Environment]::SetEnvironmentVariable("RUST_BACKTRACE", "1", "User") - ``` - -### 测试流程 - -#### 测试 1: 启动测试 -1. 双击启动 `ProxyCast.exe` -2. 查看控制台输出,应该看到: - ``` - [INFO] [Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP) - [INFO] [Bootstrap] Windows 平台 - 验证数据库文件权限 - [INFO] [Bootstrap] Windows 数据库验证成功 - ``` -3. 应用应该正常启动 - -#### 测试 2: 发送消息测试 -1. 创建新对话 -2. 发送第一条消息:"你好" -3. **预期结果**: - - ✅ 消息成功发送 - - ✅ 收到 AI 回复 - - ✅ 不会崩溃 - -#### 测试 3: 查看详细日志 -如果仍然崩溃,请: -1. 打开 PowerShell -2. 运行: - ```powershell - $env:RUST_LOG=debug; $env:RUST_BACKTRACE=1; .\ProxyCast.exe - ``` -3. 复制所有输出 - -#### 测试 4: 检查事件查看器 -1. 按 `Win + X`,选择"事件查看器" -2. 导航到:Windows 日志 → 应用程序 -3. 查找来源为 "ProxyCast" 的错误事件 -4. 导出日志(右键 → "将所有事件另存为...") - -## 常见问题排查 - -### 问题 1: 仍然崩溃 -**请收集以下信息**: -```powershell -# 1. 系统信息 -systeminfo | Select-String /C:"OS Name" /C:"OS Version" - -# 2. Rust 版本 -rustc --version - -# 3. Cargo 版本 -cargo --version - -# 4. 运行应用(带详细日志) -$env:RUST_LOG=trace; .\ProxyCast.exe > proxycast.log 2>&1 - -# 5. 检查日志文件 -Get-Content proxycast.log | Select-String -Pattern "ERROR|WARN|Bootstrap" -``` - -### 问题 2: 数据库错误 -**症状**:启动时提示"数据库初始化失败" - -**解决方案**: -```powershell -# 1. 删除现有数据库(会丢失数据,谨慎操作) -Remove-Item "$env:APPDATA\proxycast\*.db" -Force - -# 2. 重新启动应用 -.\ProxyCast.exe -``` - -### 问题 3: 权限错误 -**症状**:提示"访问被拒绝" - -**解决方案**: -```powershell -# 以管理员身份运行 -# 右键 ProxyCast.exe → "以管理员身份运行" - -# 或者修改文件夹权限 -icacls "$env:APPDATA\proxycast" /grant "$($env:USERNAME):(OI)(CI)F" /T -``` - -## 预期行为 - -### 成功启动的日志示例 -``` -[INFO] [Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP) -[INFO] [Bootstrap] Windows 平台 - 验证数据库文件权限 -[INFO] [Bootstrap] Windows 数据库验证成功 -[INFO] [启动] 插件安装器初始化成功 -[INFO] [Bootstrap] 已设置 Aster 全局 session store -``` - -### 成功发送消息的日志示例 -``` -[INFO] [AsterAgent] 发送流式消息: session=xxx, event=xxx -[INFO] [AsterAgent] Agent 初始化状态: true -[INFO] [AsterAgent] 收到 provider_config: provider_name=xxx, model_name=xxx -``` - -## 性能对比 - -### macOS vs Windows - -| 操作 | macOS | Windows | -|------|-------|---------| -| 渲染引擎 | WebKit | Chromium | -| I/O 模型 | kqueue | IOCP | -| 线程数 | 自动 (CPU核心数) | 限制为 2 | -| 文件锁 | POSIX | Windows 锁 | -| 路径格式 | `/` | `\` | - -## 联系方式 - -如果测试后仍有问题,请提供: -1. 完整的启动日志(`$env:RUST_LOG=trace`) -2. 事件查看器中的错误日志 -3. 系统信息(`systeminfo`) -4. 重现步骤的详细描述 - -## 相关文档 -- [完整分析报告](./WINDOWS_CRASH_ANALYSIS.md) -- [修复验证清单](./test-crash-fix.md) diff --git a/package.json b/package.json index 61f94bbd1..adfb839b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.70.1", + "version": "0.71.0", "type": "module", "repository": { "type": "git", @@ -9,13 +9,14 @@ }, "homepage": "https://github.com/aiclientproxy/proxycast", "scripts": { + "predev": "node scripts/ensure-dev-port.mjs", "dev": "npx vite", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "tauri:dev": "CARGO_TARGET_DIR=src-tauri/target tauri dev", - "tauri:dev:headless": "CARGO_TARGET_DIR=src-tauri/target tauri dev --config src-tauri/tauri.conf.headless.json", - "tauri:dev:nowatch": "CARGO_TARGET_DIR=src-tauri/target tauri dev --no-watch", + "tauri:dev": "CARGO_TARGET_DIR=target tauri dev", + "tauri:dev:headless": "CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", + "tauri:dev:nowatch": "CARGO_TARGET_DIR=target tauri dev --no-watch", "lint": "eslint src --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "prepare": "husky", diff --git a/scripts/ensure-dev-port.mjs b/scripts/ensure-dev-port.mjs new file mode 100644 index 000000000..0c58b2ac3 --- /dev/null +++ b/scripts/ensure-dev-port.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const devPort = process.env.PROXYCAST_DEV_PORT ?? "1420"; +const projectRoot = path.resolve(process.cwd()); +const repoRootMarker = path.join(projectRoot, "package.json"); +const nestedRepoRootMarker = path.join(projectRoot, "..", "package.json"); + +const runningInsideSrcTauri = + path.basename(projectRoot) === "src-tauri" && + fs.existsSync(nestedRepoRootMarker); + +if (runningInsideSrcTauri) { + console.error("[proxycast] 检测到在 src-tauri 子目录启动开发脚本。"); + console.error("[proxycast] 请回到仓库根目录执行:npm run tauri:dev"); + console.error( + "[proxycast] 这样可以避免生成 src-tauri/src-tauri/target 目录。", + ); + process.exit(1); +} + +if (!fs.existsSync(repoRootMarker)) { + console.error(`[proxycast] 当前目录缺少 package.json: ${projectRoot}`); + console.error("[proxycast] 请在 proxycast 仓库根目录执行开发命令。"); + process.exit(1); +} + +function run(command) { + try { + return execSync(command, { stdio: ["ignore", "pipe", "pipe"] }) + .toString("utf8") + .trim(); + } catch { + return ""; + } +} + +function listListenPids(port) { + const output = run(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`); + if (!output) { + return []; + } + return [ + ...new Set( + output + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + +function readCommand(pid) { + return run(`ps -p ${pid} -o command=`).trim(); +} + +function killPid(pid, signal) { + try { + process.kill(Number(pid), signal); + return true; + } catch { + return false; + } +} + +if (process.platform === "win32") { + process.exit(0); +} + +const occupiedPids = listListenPids(devPort); +if (occupiedPids.length === 0) { + process.exit(0); +} + +const blockedProcesses = []; +const targetPids = []; + +for (const pid of occupiedPids) { + const command = readCommand(pid); + const isViteProcess = command.includes("vite"); + const inCurrentProject = command.includes(projectRoot); + + if (isViteProcess && inCurrentProject) { + targetPids.push(pid); + } else { + blockedProcesses.push({ pid, command: command || "unknown" }); + } +} + +if (blockedProcesses.length > 0) { + console.error(`[proxycast] 端口 ${devPort} 被其他进程占用,无法自动清理:`); + for (const item of blockedProcesses) { + console.error(`- PID ${item.pid}: ${item.command}`); + } + console.error("[proxycast] 请先结束占用进程后再重试启动。"); + process.exit(1); +} + +for (const pid of targetPids) { + killPid(pid, "SIGTERM"); +} + +const stillOccupied = listListenPids(devPort); +for (const pid of stillOccupied) { + if (targetPids.includes(pid)) { + killPid(pid, "SIGKILL"); + } +} + +const unresolved = listListenPids(devPort); +if (unresolved.length > 0) { + console.error(`[proxycast] 端口 ${devPort} 仍被占用,请手动清理后重试。`); + process.exit(1); +} + +if (targetPids.length > 0) { + console.log( + `[proxycast] 已清理 ${targetPids.length} 个残留 vite 进程(端口 ${devPort})。`, + ); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1304528d8..6a4da804b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6685,7 +6685,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.70.1" +version = "0.71.0" dependencies = [ "anyhow", "arboard", @@ -6785,7 +6785,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.70.1" +version = "0.71.0" dependencies = [ "aster-core", "async-trait", @@ -6808,7 +6808,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.70.1" +version = "0.71.0" dependencies = [ "async-trait", "parking_lot", @@ -6824,7 +6824,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.70.1" +version = "0.71.0" dependencies = [ "aster-models", "async-trait", @@ -6864,7 +6864,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.70.1" +version = "0.71.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -6899,7 +6899,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.70.1" +version = "0.71.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6919,7 +6919,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.70.1" +version = "0.71.0" dependencies = [ "async-trait", "glob", @@ -6950,7 +6950,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.70.1" +version = "0.71.0" dependencies = [ "async-trait", "parking_lot", @@ -6969,7 +6969,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.70.1" +version = "0.71.0" dependencies = [ "anyhow", "async-stream", @@ -7021,7 +7021,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.70.1" +version = "0.71.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -7063,7 +7063,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.70.1" +version = "0.71.0" dependencies = [ "axum 0.7.9", "futures", @@ -7078,7 +7078,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.70.1" +version = "0.71.0" dependencies = [ "anyhow", "aster-core", @@ -7119,7 +7119,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.70.1" +version = "0.71.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7135,7 +7135,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.70.1" +version = "0.71.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7162,7 +7162,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.70.1" +version = "0.71.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cb7e481fd..38a4491da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.70.1" +version = "0.71.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -189,7 +189,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.70.1" +version = "0.71.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index 0b123d581..b19e60f84 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -130,6 +130,7 @@ impl SessionConfigBuilder { max_turns: self.max_turns, retry_config: None, system_prompt: self.system_prompt, + include_context_trace: None, } } } diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index abb4eb54d..93d8c151b 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -391,6 +391,10 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec { tracing::debug!("History replaced"); vec![] } + AgentEvent::ContextTrace { steps } => { + tracing::debug!("Context trace received, steps: {}", steps.len()); + vec![] + } } } diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index f4376e9c5..93ff359a1 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -1809,6 +1809,9 @@ pub struct ChatAppearanceConfig { /// 显示时间戳 #[serde(default)] pub show_timestamp: Option, + /// 推荐点击时自动附带当前选中文本上下文 + #[serde(default)] + pub append_selected_text_to_recommendation: Option, } /// 记忆管理配置 diff --git a/src-tauri/crates/core/src/database/dao/mod.rs b/src-tauri/crates/core/src/database/dao/mod.rs index 21cfba375..72138cbb9 100644 --- a/src-tauri/crates/core/src/database/dao/mod.rs +++ b/src-tauri/crates/core/src/database/dao/mod.rs @@ -18,3 +18,4 @@ pub mod providers; pub mod publish_config_dao; pub mod skills; pub mod template_dao; +pub mod video_generation_task_dao; diff --git a/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs b/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs new file mode 100644 index 000000000..bfd9e5736 --- /dev/null +++ b/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs @@ -0,0 +1,244 @@ +//! 视频生成任务数据访问层 +//! +//! 提供视频生成任务(`video_generation_tasks`)的 CRUD 操作。 + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 视频生成任务状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VideoGenerationTaskStatus { + Pending, + Processing, + Success, + Error, + Cancelled, +} + +impl VideoGenerationTaskStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Processing => "processing", + Self::Success => "success", + Self::Error => "error", + Self::Cancelled => "cancelled", + } + } + + pub fn from_db(value: &str) -> Self { + match value { + "pending" => Self::Pending, + "processing" => Self::Processing, + "success" => Self::Success, + "error" => Self::Error, + "cancelled" => Self::Cancelled, + _ => Self::Error, + } + } +} + +/// 视频生成任务 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VideoGenerationTask { + pub id: String, + pub project_id: String, + pub provider_id: String, + pub model: String, + pub prompt: String, + pub request_payload: Option, + pub provider_task_id: Option, + pub status: VideoGenerationTaskStatus, + pub progress: Option, + pub result_url: Option, + pub error_message: Option, + pub metadata_json: Option, + pub created_at: i64, + pub updated_at: i64, + pub finished_at: Option, +} + +/// 创建视频任务参数 +#[derive(Debug, Clone)] +pub struct CreateVideoGenerationTaskParams { + pub project_id: String, + pub provider_id: String, + pub model: String, + pub prompt: String, + pub request_payload: Option, + pub metadata_json: Option, +} + +/// 更新视频任务状态参数 +#[derive(Debug, Clone, Default)] +pub struct UpdateVideoGenerationTaskParams { + pub provider_task_id: Option>, + pub status: Option, + pub progress: Option>, + pub result_url: Option>, + pub error_message: Option>, + pub metadata_json: Option>, + pub finished_at: Option>, +} + +/// 视频任务 DAO +pub struct VideoGenerationTaskDao; + +impl VideoGenerationTaskDao { + /// 创建视频生成任务 + pub fn create( + conn: &Connection, + params: &CreateVideoGenerationTaskParams, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + let id = Uuid::new_v4().to_string(); + + conn.execute( + "INSERT INTO video_generation_tasks ( + id, project_id, provider_id, model, prompt, request_payload, provider_task_id, + status, progress, result_url, error_message, metadata_json, + created_at, updated_at, finished_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL, NULL, NULL, ?8, ?9, ?10, NULL)", + params![ + id, + params.project_id, + params.provider_id, + params.model, + params.prompt, + params.request_payload, + VideoGenerationTaskStatus::Pending.as_str(), + params.metadata_json, + now, + now, + ], + )?; + + Self::get_by_id(conn, &id).map(|task| task.expect("刚创建的任务必须可读取")) + } + + /// 按 ID 获取任务 + pub fn get_by_id( + conn: &Connection, + id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT + id, project_id, provider_id, model, prompt, request_payload, provider_task_id, + status, progress, result_url, error_message, metadata_json, + created_at, updated_at, finished_at + FROM video_generation_tasks + WHERE id = ?1", + )?; + + stmt.query_row([id], Self::map_row).optional() + } + + /// 按项目列出任务(按创建时间倒序) + pub fn list_by_project( + conn: &Connection, + project_id: &str, + limit: i64, + ) -> Result, rusqlite::Error> { + let bounded_limit = limit.clamp(1, 200); + let mut stmt = conn.prepare( + "SELECT + id, project_id, provider_id, model, prompt, request_payload, provider_task_id, + status, progress, result_url, error_message, metadata_json, + created_at, updated_at, finished_at + FROM video_generation_tasks + WHERE project_id = ?1 + ORDER BY created_at DESC + LIMIT ?2", + )?; + + let rows = stmt.query_map(params![project_id, bounded_limit], Self::map_row)?; + Ok(rows.filter_map(|row| row.ok()).collect()) + } + + /// 更新任务状态 + pub fn update_task( + conn: &Connection, + id: &str, + params: &UpdateVideoGenerationTaskParams, + ) -> Result, rusqlite::Error> { + let mut task = match Self::get_by_id(conn, id)? { + Some(value) => value, + None => return Ok(None), + }; + + if let Some(provider_task_id) = ¶ms.provider_task_id { + task.provider_task_id = provider_task_id.clone(); + } + if let Some(status) = params.status { + task.status = status; + } + if let Some(progress) = ¶ms.progress { + task.progress = *progress; + } + if let Some(result_url) = ¶ms.result_url { + task.result_url = result_url.clone(); + } + if let Some(error_message) = ¶ms.error_message { + task.error_message = error_message.clone(); + } + if let Some(metadata_json) = ¶ms.metadata_json { + task.metadata_json = metadata_json.clone(); + } + if let Some(finished_at) = params.finished_at { + task.finished_at = finished_at; + } + + task.updated_at = chrono::Utc::now().timestamp(); + + conn.execute( + "UPDATE video_generation_tasks + SET provider_task_id = ?2, + status = ?3, + progress = ?4, + result_url = ?5, + error_message = ?6, + metadata_json = ?7, + updated_at = ?8, + finished_at = ?9 + WHERE id = ?1", + params![ + task.id, + task.provider_task_id, + task.status.as_str(), + task.progress, + task.result_url, + task.error_message, + task.metadata_json, + task.updated_at, + task.finished_at, + ], + )?; + + Ok(Some(task)) + } + + fn map_row(row: &rusqlite::Row<'_>) -> Result { + let status_value: String = row.get(7)?; + + Ok(VideoGenerationTask { + id: row.get(0)?, + project_id: row.get(1)?, + provider_id: row.get(2)?, + model: row.get(3)?, + prompt: row.get(4)?, + request_payload: row.get(5)?, + provider_task_id: row.get(6)?, + status: VideoGenerationTaskStatus::from_db(&status_value), + progress: row.get(8)?, + result_url: row.get(9)?, + error_message: row.get(10)?, + metadata_json: row.get(11)?, + created_at: row.get(12)?, + updated_at: row.get(13)?, + finished_at: row.get(14)?, + }) + } +} diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index df03db57e..23935f969 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -786,6 +786,45 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // ============================================================================ + // 视频生成任务表 (VideoGenerationTask) + // 存储视频生成任务状态与结果 + // ============================================================================ + conn.execute( + "CREATE TABLE IF NOT EXISTS video_generation_tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + prompt TEXT NOT NULL, + request_payload TEXT, + provider_task_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + progress INTEGER, + result_url TEXT, + error_message TEXT, + metadata_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER, + FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_video_tasks_project_created ON video_generation_tasks(project_id, created_at DESC)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_video_tasks_status ON video_generation_tasks(status)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_video_tasks_provider_task ON video_generation_tasks(provider_task_id)", + [], + )?; + // ============================================================================ // 排版模板表 (Template) // 存储项目级排版模板,用于控制 AI 输出内容的格式 diff --git a/src-tauri/crates/core/src/workspace/types.rs b/src-tauri/crates/core/src/workspace/types.rs index 391634f1b..71834a0b8 100644 --- a/src-tauri/crates/core/src/workspace/types.rs +++ b/src-tauri/crates/core/src/workspace/types.rs @@ -187,18 +187,6 @@ pub struct WorkspaceUpdate { pub tags: Option>, } -/// Workspace 创建请求 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceCreateRequest { - /// 显示名称 - pub name: String, - /// 根目录路径 - pub root_path: String, - /// Workspace 类型(可选,默认 persistent) - #[serde(default)] - pub workspace_type: WorkspaceType, -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/crates/services/src/lib.rs b/src-tauri/crates/services/src/lib.rs index e854c7108..8d7a32c53 100644 --- a/src-tauri/crates/services/src/lib.rs +++ b/src-tauri/crates/services/src/lib.rs @@ -92,3 +92,4 @@ pub mod api_key_provider_service; pub mod provider_pool_service; pub mod provider_type_mapping; pub mod token_cache_service; +pub mod video_generation_service; diff --git a/src-tauri/crates/services/src/video_generation_service.rs b/src-tauri/crates/services/src/video_generation_service.rs new file mode 100644 index 000000000..a88d152b5 --- /dev/null +++ b/src-tauri/crates/services/src/video_generation_service.rs @@ -0,0 +1,977 @@ +//! 视频生成服务 +//! +//! 提供视频生成任务创建、状态轮询与结果管理能力。 + +use std::time::Duration; + +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use proxycast_core::database::dao::api_key_provider::ApiKeyProvider; +use proxycast_core::database::dao::material_dao::MaterialDao; +use proxycast_core::database::dao::video_generation_task_dao::{ + CreateVideoGenerationTaskParams, UpdateVideoGenerationTaskParams, VideoGenerationTask, + VideoGenerationTaskDao, VideoGenerationTaskStatus, +}; +use proxycast_core::database::{lock_db, DbConnection}; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::api_key_provider_service::ApiKeyProviderService; + +const DEFAULT_TIMEOUT_SECS: u64 = 45; +const DEFAULT_VOLCENGINE_HOST: &str = "https://ark.cn-beijing.volces.com/api/v3"; +const DEFAULT_DASHSCOPE_HOST: &str = "https://dashscope.aliyuncs.com"; +const MATERIAL_URL_PREFIX: &str = "material://"; + +/// 创建视频任务请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateVideoGenerationRequest { + pub project_id: String, + pub provider_id: String, + pub model: String, + pub prompt: String, + pub aspect_ratio: Option, + pub resolution: Option, + pub duration: Option, + pub image_url: Option, + pub end_image_url: Option, + pub seed: Option, + pub generate_audio: Option, + pub camera_fixed: Option, +} + +/// 视频任务状态响应(用于 Provider 轮询) +#[derive(Debug, Clone)] +struct ProviderTaskStatus { + status: VideoGenerationTaskStatus, + progress: Option, + video_url: Option, + error_message: Option, +} + +/// Provider 适配器上下文 +#[derive(Debug, Clone)] +struct AdapterContext { + api_host: String, + api_key: String, +} + +#[async_trait] +trait VideoProviderAdapter { + async fn submit( + &self, + client: &Client, + context: &AdapterContext, + request: &CreateVideoGenerationRequest, + ) -> Result; + + async fn query( + &self, + client: &Client, + context: &AdapterContext, + provider_task_id: &str, + ) -> Result; + + async fn cancel( + &self, + _client: &Client, + _context: &AdapterContext, + _provider_task_id: &str, + ) -> Result<(), String> { + Ok(()) + } +} + +struct VolcengineVideoAdapter; +struct DashscopeVideoAdapter; + +#[async_trait] +impl VideoProviderAdapter for VolcengineVideoAdapter { + async fn submit( + &self, + client: &Client, + context: &AdapterContext, + request: &CreateVideoGenerationRequest, + ) -> Result { + let base_url = normalize_host( + if context.api_host.trim().is_empty() { + DEFAULT_VOLCENGINE_HOST + } else { + &context.api_host + }, + DEFAULT_VOLCENGINE_HOST, + ); + let endpoint = format!( + "{}/contents/generations/tasks", + base_url.trim_end_matches('/') + ); + + let mut content = vec![json!({ + "type": "text", + "text": request.prompt + })]; + + if let Some(image_url) = &request.image_url { + if !image_url.trim().is_empty() { + content.push(json!({ + "type": "image_url", + "role": "first_frame", + "image_url": { "url": image_url } + })); + } + } + + if let Some(end_image_url) = &request.end_image_url { + if !end_image_url.trim().is_empty() { + content.push(json!({ + "type": "image_url", + "role": "last_frame", + "image_url": { "url": end_image_url } + })); + } + } + + let mut body = Map::new(); + body.insert("model".to_string(), Value::String(request.model.clone())); + body.insert("content".to_string(), Value::Array(content)); + body.insert("watermark".to_string(), Value::Bool(false)); + + if let Some(aspect_ratio) = &request.aspect_ratio { + if !aspect_ratio.trim().is_empty() && aspect_ratio != "adaptive" { + body.insert("ratio".to_string(), Value::String(aspect_ratio.clone())); + } + } + if let Some(duration) = request.duration { + body.insert("duration".to_string(), Value::Number(duration.into())); + } + if let Some(seed) = request.seed { + body.insert("seed".to_string(), Value::Number(seed.into())); + } + if let Some(generate_audio) = request.generate_audio { + body.insert("generate_audio".to_string(), Value::Bool(generate_audio)); + } + if let Some(camera_fixed) = request.camera_fixed { + body.insert("camera_fixed".to_string(), Value::Bool(camera_fixed)); + } + if let Some(resolution) = &request.resolution { + if !resolution.trim().is_empty() { + body.insert("resolution".to_string(), Value::String(resolution.clone())); + } + } + + let response = client + .post(endpoint) + .header(AUTHORIZATION, format!("Bearer {}", context.api_key)) + .header(CONTENT_TYPE, "application/json") + .json(&Value::Object(body)) + .send() + .await + .map_err(|error| format!("火山视频任务提交失败: {error}"))?; + + let status = response.status(); + let payload = response + .text() + .await + .map_err(|error| format!("火山视频响应读取失败: {error}"))?; + + if !status.is_success() { + return Err(format!( + "火山视频任务提交失败 ({}): {}", + status.as_u16(), + preview_payload(&payload) + )); + } + + let value: Value = serde_json::from_str(&payload) + .map_err(|error| format!("火山视频响应解析失败: {error}"))?; + + find_string_value(&value, &["id", "task_id"]) + .ok_or_else(|| "火山视频响应缺少任务 ID".to_string()) + } + + async fn query( + &self, + client: &Client, + context: &AdapterContext, + provider_task_id: &str, + ) -> Result { + let base_url = normalize_host( + if context.api_host.trim().is_empty() { + DEFAULT_VOLCENGINE_HOST + } else { + &context.api_host + }, + DEFAULT_VOLCENGINE_HOST, + ); + let endpoint = format!( + "{}/contents/generations/tasks/{}", + base_url.trim_end_matches('/'), + provider_task_id + ); + + let response = client + .get(endpoint) + .header(AUTHORIZATION, format!("Bearer {}", context.api_key)) + .send() + .await + .map_err(|error| format!("火山视频任务查询失败: {error}"))?; + + let status_code = response.status(); + let payload = response + .text() + .await + .map_err(|error| format!("火山视频查询响应读取失败: {error}"))?; + + if !status_code.is_success() { + return Err(format!( + "火山视频任务查询失败 ({}): {}", + status_code.as_u16(), + preview_payload(&payload) + )); + } + + let value: Value = serde_json::from_str(&payload) + .map_err(|error| format!("火山视频查询响应解析失败: {error}"))?; + + let raw_status = find_string_value( + &value, + &[ + "status", + "state", + "task_status", + "taskStatus", + "output.task_status", + ], + ) + .unwrap_or_else(|| "processing".to_string()); + + let progress = find_i64_value( + &value, + &[ + "progress", + "task_progress", + "output.task_progress", + "output.progress", + ], + ); + + let video_url = extract_video_url(&value); + let normalized_status = normalize_provider_status(&raw_status); + let error_message = if normalized_status == VideoGenerationTaskStatus::Error { + find_string_value(&value, &["error", "error_message", "message", "msg"]) + .or_else(|| Some("视频生成失败".to_string())) + } else { + None + }; + + Ok(ProviderTaskStatus { + status: normalized_status, + progress, + video_url, + error_message, + }) + } +} + +#[async_trait] +impl VideoProviderAdapter for DashscopeVideoAdapter { + async fn submit( + &self, + client: &Client, + context: &AdapterContext, + request: &CreateVideoGenerationRequest, + ) -> Result { + let base_url = normalize_host( + if context.api_host.trim().is_empty() { + DEFAULT_DASHSCOPE_HOST + } else { + &context.api_host + }, + DEFAULT_DASHSCOPE_HOST, + ); + let endpoint = format!( + "{}/api/v1/services/aigc/video-generation/video-synthesis", + base_url.trim_end_matches('/') + ); + + let mut input = Map::new(); + input.insert("prompt".to_string(), Value::String(request.prompt.clone())); + if let Some(image_url) = &request.image_url { + if !image_url.trim().is_empty() { + input.insert("image_url".to_string(), Value::String(image_url.clone())); + } + } + if let Some(end_image_url) = &request.end_image_url { + if !end_image_url.trim().is_empty() { + input.insert( + "end_image_url".to_string(), + Value::String(end_image_url.clone()), + ); + } + } + + let mut parameters = Map::new(); + if let Some(size) = resolve_dashscope_size( + request.resolution.as_deref(), + request.aspect_ratio.as_deref(), + ) { + parameters.insert("size".to_string(), Value::String(size)); + } + if let Some(duration) = request.duration { + parameters.insert("duration".to_string(), Value::Number(duration.into())); + } + if let Some(seed) = request.seed { + parameters.insert("seed".to_string(), Value::Number(seed.into())); + } + if let Some(camera_fixed) = request.camera_fixed { + parameters.insert("camera_fixed".to_string(), Value::Bool(camera_fixed)); + } + + let mut body = Map::new(); + body.insert("model".to_string(), Value::String(request.model.clone())); + body.insert("input".to_string(), Value::Object(input)); + if !parameters.is_empty() { + body.insert("parameters".to_string(), Value::Object(parameters)); + } + + let response = client + .post(endpoint) + .header(AUTHORIZATION, format!("Bearer {}", context.api_key)) + .header(CONTENT_TYPE, "application/json") + .header("X-DashScope-Async", "enable") + .json(&Value::Object(body)) + .send() + .await + .map_err(|error| format!("阿里视频任务提交失败: {error}"))?; + + let status = response.status(); + let payload = response + .text() + .await + .map_err(|error| format!("阿里视频响应读取失败: {error}"))?; + + if !status.is_success() { + return Err(format!( + "阿里视频任务提交失败 ({}): {}", + status.as_u16(), + preview_payload(&payload) + )); + } + + let value: Value = serde_json::from_str(&payload) + .map_err(|error| format!("阿里视频响应解析失败: {error}"))?; + + find_string_value(&value, &["output.task_id", "task_id", "id"]) + .ok_or_else(|| "阿里视频响应缺少任务 ID".to_string()) + } + + async fn query( + &self, + client: &Client, + context: &AdapterContext, + provider_task_id: &str, + ) -> Result { + let base_url = normalize_host( + if context.api_host.trim().is_empty() { + DEFAULT_DASHSCOPE_HOST + } else { + &context.api_host + }, + DEFAULT_DASHSCOPE_HOST, + ); + let endpoint = format!( + "{}/api/v1/tasks/{}", + base_url.trim_end_matches('/'), + provider_task_id + ); + + let response = client + .get(endpoint) + .header(AUTHORIZATION, format!("Bearer {}", context.api_key)) + .send() + .await + .map_err(|error| format!("阿里视频任务查询失败: {error}"))?; + + let status_code = response.status(); + let payload = response + .text() + .await + .map_err(|error| format!("阿里视频查询响应读取失败: {error}"))?; + + if !status_code.is_success() { + return Err(format!( + "阿里视频任务查询失败 ({}): {}", + status_code.as_u16(), + preview_payload(&payload) + )); + } + + let value: Value = serde_json::from_str(&payload) + .map_err(|error| format!("阿里视频查询响应解析失败: {error}"))?; + + let raw_status = find_string_value( + &value, + &[ + "output.task_status", + "task_status", + "status", + "state", + "output.status", + ], + ) + .unwrap_or_else(|| "processing".to_string()); + let progress = find_i64_value( + &value, + &["output.task_progress", "task_progress", "progress"], + ); + let video_url = extract_video_url(&value); + + let normalized_status = normalize_provider_status(&raw_status); + let error_message = if normalized_status == VideoGenerationTaskStatus::Error { + find_string_value( + &value, + &["output.message", "message", "error_message", "msg"], + ) + .or_else(|| Some("视频生成失败".to_string())) + } else { + None + }; + + Ok(ProviderTaskStatus { + status: normalized_status, + progress, + video_url, + error_message, + }) + } +} + +fn normalize_host(api_host: &str, fallback: &str) -> String { + let trimmed = api_host.trim(); + if trimmed.is_empty() { + return fallback.trim_end_matches('/').to_string(); + } + let with_protocol = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + with_protocol.trim_end_matches('/').to_string() +} + +fn preview_payload(payload: &str) -> String { + if payload.len() <= 280 { + return payload.to_string(); + } + format!("{}...", &payload[..280]) +} + +fn normalize_provider_status(raw_status: &str) -> VideoGenerationTaskStatus { + let normalized = raw_status.trim().to_uppercase(); + if normalized.contains("SUCCEED") + || normalized.contains("SUCCESS") + || normalized == "DONE" + || normalized == "COMPLETED" + { + return VideoGenerationTaskStatus::Success; + } + if normalized.contains("FAIL") || normalized.contains("ERROR") { + return VideoGenerationTaskStatus::Error; + } + if normalized.contains("CANCEL") { + return VideoGenerationTaskStatus::Cancelled; + } + if normalized.contains("PENDING") + || normalized.contains("RUNNING") + || normalized.contains("PROCESSING") + || normalized.contains("QUEUE") + || normalized.contains("SUBMITTED") + { + return VideoGenerationTaskStatus::Processing; + } + VideoGenerationTaskStatus::Processing +} + +fn find_value_by_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let mut current = value; + for segment in path.split('.') { + match current { + Value::Object(map) => { + current = map.get(segment)?; + } + Value::Array(items) => { + let index = segment.parse::().ok()?; + current = items.get(index)?; + } + _ => return None, + } + } + Some(current) +} + +fn find_string_value(value: &Value, paths: &[&str]) -> Option { + for path in paths { + if let Some(candidate) = find_value_by_path(value, path) { + match candidate { + Value::String(text) => { + if !text.trim().is_empty() { + return Some(text.clone()); + } + } + Value::Number(number) => { + return Some(number.to_string()); + } + _ => {} + } + } + } + None +} + +fn find_i64_value(value: &Value, paths: &[&str]) -> Option { + for path in paths { + if let Some(candidate) = find_value_by_path(value, path) { + match candidate { + Value::Number(number) => { + if let Some(integer) = number.as_i64() { + return Some(integer); + } + } + Value::String(text) => { + if let Ok(parsed) = text.parse::() { + return Some(parsed); + } + } + _ => {} + } + } + } + None +} + +fn extract_video_url(value: &Value) -> Option { + if let Some(url) = find_string_value( + value, + &[ + "output.video_url", + "output.url", + "video_url", + "url", + "result.video_url", + "result.url", + "output.video_urls.0", + ], + ) { + if url.starts_with("http://") || url.starts_with("https://") { + return Some(url); + } + } + + if let Some(results) = find_value_by_path(value, "output.results") { + if let Value::Array(items) = results { + for item in items { + if let Some(url) = find_string_value(item, &["url", "video_url"]) { + if url.starts_with("http://") || url.starts_with("https://") { + return Some(url); + } + } + } + } + } + + None +} + +fn resolve_dashscope_size(resolution: Option<&str>, aspect_ratio: Option<&str>) -> Option { + let ratio = aspect_ratio.unwrap_or("16:9"); + let normalized_ratio = if ratio == "adaptive" { "16:9" } else { ratio }; + let normalized_resolution = resolution.unwrap_or("720p").to_lowercase(); + + let value = match (normalized_resolution.as_str(), normalized_ratio) { + ("1080p", "16:9") => "1920*1080", + ("1080p", "9:16") => "1080*1920", + ("1080p", "1:1") => "1536*1536", + ("720p", "16:9") => "1280*720", + ("720p", "9:16") => "720*1280", + ("720p", "1:1") => "1024*1024", + ("480p", "16:9") => "854*480", + ("480p", "9:16") => "480*854", + ("480p", "1:1") => "720*720", + _ => "1280*720", + }; + + Some(value.to_string()) +} + +fn infer_mime_type_from_path(path: &str) -> &'static str { + let extension = std::path::Path::new(path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_lowercase(); + + match extension.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + +fn build_data_url(mime_type: &str, bytes: &[u8]) -> String { + format!("data:{mime_type};base64,{}", BASE64.encode(bytes)) +} + +fn resolve_material_reference_url(db: &DbConnection, raw_url: &str) -> Result { + if !raw_url.starts_with(MATERIAL_URL_PREFIX) { + return Ok(raw_url.to_string()); + } + + let material_id = raw_url + .trim_start_matches(MATERIAL_URL_PREFIX) + .trim() + .to_string(); + if material_id.is_empty() { + return Err("素材引用 URL 无效:缺少 material id".to_string()); + } + + let material = { + let conn = lock_db(db)?; + MaterialDao::get(&conn, &material_id).map_err(|error| format!("读取素材失败: {error}"))? + } + .ok_or_else(|| format!("素材不存在: {material_id}"))?; + + let file_path = material + .file_path + .ok_or_else(|| format!("素材缺少文件路径: {material_id}"))?; + let bytes = std::fs::read(&file_path).map_err(|error| format!("读取素材文件失败: {error}"))?; + let mime_type = material + .mime_type + .unwrap_or_else(|| infer_mime_type_from_path(&file_path).to_string()); + + Ok(build_data_url(&mime_type, &bytes)) +} + +fn resolve_submit_request( + db: &DbConnection, + request: &CreateVideoGenerationRequest, +) -> Result { + let mut resolved = request.clone(); + if let Some(image_url) = &request.image_url { + if !image_url.trim().is_empty() { + resolved.image_url = Some(resolve_material_reference_url(db, image_url)?); + } + } + if let Some(end_image_url) = &request.end_image_url { + if !end_image_url.trim().is_empty() { + resolved.end_image_url = Some(resolve_material_reference_url(db, end_image_url)?); + } + } + Ok(resolved) +} + +fn resolve_adapter( + provider: &ApiKeyProvider, +) -> Result, String> { + let provider_id = provider.id.to_lowercase(); + let api_host = provider.api_host.to_lowercase(); + + if provider_id.contains("doubao") + || provider_id.contains("volc") + || api_host.contains("volces.com") + || api_host.contains("volcengine.com") + { + return Ok(Box::new(VolcengineVideoAdapter)); + } + + if provider_id.contains("dashscope") + || provider_id.contains("alibaba") + || provider_id.contains("qwen") + || api_host.contains("dashscope.aliyuncs.com") + { + return Ok(Box::new(DashscopeVideoAdapter)); + } + + Err(format!( + "当前 Provider 尚未实现视频生成适配: {} (api_host={})", + provider.id, provider.api_host + )) +} + +/// 视频生成服务 +pub struct VideoGenerationService { + client: Client, +} + +impl Default for VideoGenerationService { + fn default() -> Self { + Self::new() + } +} + +impl VideoGenerationService { + pub fn new() -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| Client::new()); + Self { client } + } + + pub async fn create_task( + &self, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderService, + request: CreateVideoGenerationRequest, + ) -> Result { + let provider_with_keys = api_key_provider_service + .get_provider(db, &request.provider_id)? + .ok_or_else(|| format!("Provider 不存在: {}", request.provider_id))?; + let provider = provider_with_keys.provider; + + if !provider.enabled { + return Err(format!("Provider 已禁用: {}", provider.id)); + } + + let request_payload = serde_json::to_string(&request) + .map_err(|error| format!("视频任务请求序列化失败: {error}"))?; + + let mut task = { + let conn = lock_db(db)?; + VideoGenerationTaskDao::create( + &conn, + &CreateVideoGenerationTaskParams { + project_id: request.project_id.clone(), + provider_id: request.provider_id.clone(), + model: request.model.clone(), + prompt: request.prompt.clone(), + request_payload: Some(request_payload), + metadata_json: None, + }, + ) + .map_err(|error| format!("视频任务创建失败: {error}"))? + }; + + let (selected_key_id, selected_api_key) = api_key_provider_service + .get_next_api_key_entry(db, &provider.id)? + .ok_or_else(|| format!("Provider 没有可用的 API Key: {}", provider.id))?; + + let adapter = resolve_adapter(&provider)?; + let context = AdapterContext { + api_host: provider.api_host.clone(), + api_key: selected_api_key, + }; + let submit_request = resolve_submit_request(db, &request)?; + + match adapter + .submit(&self.client, &context, &submit_request) + .await + { + Ok(provider_task_id) => { + let updated = { + let conn = lock_db(db)?; + VideoGenerationTaskDao::update_task( + &conn, + &task.id, + &UpdateVideoGenerationTaskParams { + provider_task_id: Some(Some(provider_task_id)), + status: Some(VideoGenerationTaskStatus::Processing), + progress: Some(Some(0)), + result_url: None, + error_message: None, + metadata_json: None, + finished_at: Some(None), + }, + ) + .map_err(|error| format!("视频任务更新失败: {error}"))? + }; + + api_key_provider_service.record_usage(db, &selected_key_id)?; + + task = updated.ok_or_else(|| "视频任务更新后丢失".to_string())?; + Ok(task) + } + Err(error_message) => { + { + let conn = lock_db(db)?; + let _ = VideoGenerationTaskDao::update_task( + &conn, + &task.id, + &UpdateVideoGenerationTaskParams { + status: Some(VideoGenerationTaskStatus::Error), + error_message: Some(Some(error_message.clone())), + finished_at: Some(Some(chrono::Utc::now().timestamp())), + ..Default::default() + }, + ); + } + let _ = api_key_provider_service.record_error(db, &selected_key_id); + + Err(error_message) + } + } + } + + pub async fn get_task( + &self, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderService, + task_id: &str, + refresh_status: bool, + ) -> Result, String> { + let task = { + let conn = lock_db(db)?; + VideoGenerationTaskDao::get_by_id(&conn, task_id) + .map_err(|error| format!("读取视频任务失败: {error}"))? + }; + + let mut task = match task { + Some(value) => value, + None => return Ok(None), + }; + + if !refresh_status { + return Ok(Some(task)); + } + + if task.status != VideoGenerationTaskStatus::Pending + && task.status != VideoGenerationTaskStatus::Processing + { + return Ok(Some(task)); + } + + let provider_task_id = match &task.provider_task_id { + Some(value) if !value.trim().is_empty() => value.clone(), + _ => return Ok(Some(task)), + }; + + let provider_with_keys = api_key_provider_service + .get_provider(db, &task.provider_id)? + .ok_or_else(|| format!("Provider 不存在: {}", task.provider_id))?; + let provider = provider_with_keys.provider; + let (_key_id, api_key) = api_key_provider_service + .get_next_api_key_entry(db, &provider.id)? + .ok_or_else(|| format!("Provider 没有可用的 API Key: {}", provider.id))?; + + let adapter = resolve_adapter(&provider)?; + let context = AdapterContext { + api_host: provider.api_host.clone(), + api_key, + }; + + let status = match adapter + .query(&self.client, &context, &provider_task_id) + .await + { + Ok(value) => value, + Err(error_message) => ProviderTaskStatus { + status: VideoGenerationTaskStatus::Error, + progress: None, + video_url: None, + error_message: Some(error_message), + }, + }; + + let updated_task = { + let conn = lock_db(db)?; + VideoGenerationTaskDao::update_task( + &conn, + &task.id, + &UpdateVideoGenerationTaskParams { + status: Some(status.status), + progress: Some(status.progress), + result_url: Some(status.video_url), + error_message: Some(status.error_message), + finished_at: if matches!( + status.status, + VideoGenerationTaskStatus::Success + | VideoGenerationTaskStatus::Error + | VideoGenerationTaskStatus::Cancelled + ) { + Some(Some(chrono::Utc::now().timestamp())) + } else { + Some(None) + }, + ..Default::default() + }, + ) + .map_err(|error| format!("更新视频任务状态失败: {error}"))? + }; + + if let Some(updated) = updated_task { + task = updated; + } + + Ok(Some(task)) + } + + pub fn list_tasks( + &self, + db: &DbConnection, + project_id: &str, + limit: i64, + ) -> Result, String> { + let conn = lock_db(db)?; + VideoGenerationTaskDao::list_by_project(&conn, project_id, limit) + .map_err(|error| format!("读取视频任务列表失败: {error}")) + } + + pub async fn cancel_task( + &self, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderService, + task_id: &str, + ) -> Result, String> { + let task = { + let conn = lock_db(db)?; + VideoGenerationTaskDao::get_by_id(&conn, task_id) + .map_err(|error| format!("读取视频任务失败: {error}"))? + }; + + let task = match task { + Some(value) => value, + None => return Ok(None), + }; + + if let Some(provider_task_id) = &task.provider_task_id { + if let Some(provider_with_keys) = + api_key_provider_service.get_provider(db, &task.provider_id)? + { + if let Some((_key_id, api_key)) = api_key_provider_service + .get_next_api_key_entry(db, &provider_with_keys.provider.id)? + { + let adapter = resolve_adapter(&provider_with_keys.provider)?; + let context = AdapterContext { + api_host: provider_with_keys.provider.api_host.clone(), + api_key, + }; + let _ = adapter + .cancel(&self.client, &context, provider_task_id) + .await; + } + } + } + + let updated = { + let conn = lock_db(db)?; + VideoGenerationTaskDao::update_task( + &conn, + task_id, + &UpdateVideoGenerationTaskParams { + status: Some(VideoGenerationTaskStatus::Cancelled), + finished_at: Some(Some(chrono::Utc::now().timestamp())), + ..Default::default() + }, + ) + .map_err(|error| format!("取消视频任务失败: {error}"))? + }; + + Ok(updated) + } +} diff --git a/src-tauri/install-local.sh b/src-tauri/install-local.sh new file mode 100755 index 000000000..fa37ff8c5 --- /dev/null +++ b/src-tauri/install-local.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +echo "🔧 ProxyCast 本地安装脚本" +echo "================================" + +# 1. 更新 Rust +echo "📦 检查 Rust 版本..." +CURRENT_VERSION=$(rustc --version | awk '{print $2}') +echo "当前版本: $CURRENT_VERSION" + +if ! rustc --version | grep -q "1.9"; then + echo "⚠️ Rust 版本过低,正在更新..." + rustup update stable + source "$HOME/.cargo/env" +fi + +echo "✅ Rust 版本: $(rustc --version | awk '{print $1,$2}')" + +# 2. 清理之前的构建 +echo "" +echo "🧹 清理之前的构建..." +cargo clean 2>/dev/null || true + +# 3. 编译 +echo "" +echo "🔨 开始编译 (dev 模式)..." +cargo build 2>&1 | tee /tmp/proxycast_build.log + +BUILD_STATUS=${PIPESTATUS[0]} +if [ $BUILD_STATUS -ne 0 ]; then + echo "❌ 编译失败!查看日志: /tmp/proxycast_build.log" + tail -50 /tmp/proxycast_build.log + exit 1 +fi + +echo "✅ 编译成功" + +# 4. 本地安装 +echo "" +echo "📦 正在本地安装..." +cargo install --path . --force 2>&1 | tee /tmp/proxycast_install.log + +INSTALL_STATUS=${PIPESTATUS[0]} +if [ $INSTALL_STATUS -ne 0 ]; then + echo "❌ 安装失败!查看日志: /tmp/proxycast_install.log" + tail -50 /tmp/proxycast_install.log + exit 1 +fi + +echo "✅ 安装成功" + +# 5. 验证安装 +echo "" +echo "🔍 验证安装..." +if command -v proxycast &> /dev/null; then + echo "✅ ProxyCast 已安装到: $(which proxycast)" +else + echo "⚠️ ProxyCast 命令行工具未在 PATH 中" + echo "安装位置: ~/.cargo/bin/proxycast" + echo "" + echo "请将以下内容添加到 ~/.zshrc 或 ~/.bash_profile:" + echo 'export PATH="$HOME/.cargo/bin:$PATH"' +fi + +echo "" +echo "🎉 安装完成!" +echo "" +echo "运行应用:" +echo " 开发模式: cd .. && npm run tauri dev" +echo " 构建应用: npm run tauri build" diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index d030d1550..f79e8a69e 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1256,6 +1256,11 @@ pub fn run() { commands::material_cmd::get_material_content, commands::material_cmd::get_material_count, commands::material_cmd::get_materials_content, + // Video generation commands + commands::video_generation_cmd::create_video_generation_task, + commands::video_generation_cmd::get_video_generation_task, + commands::video_generation_cmd::list_video_generation_tasks, + commands::video_generation_cmd::cancel_video_generation_task, // Poster Material commands commands::poster_material_cmd::create_poster_metadata, commands::poster_material_cmd::get_poster_metadata, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1115f53da..65bf2a093 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -59,6 +59,7 @@ pub mod unified_memory_cmd; pub mod update_cmd; pub mod usage_cmd; pub mod usage_stats_cmd; +pub mod video_generation_cmd; pub mod voice_test_cmd; pub mod websocket_cmd; pub mod webview_cmd; diff --git a/src-tauri/src/commands/video_generation_cmd.rs b/src-tauri/src/commands/video_generation_cmd.rs new file mode 100644 index 000000000..13e02ac5c --- /dev/null +++ b/src-tauri/src/commands/video_generation_cmd.rs @@ -0,0 +1,90 @@ +//! 视频生成命令 +//! +//! 提供视频任务创建、轮询、列表和取消命令。 + +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::database::DbConnection; +use proxycast_core::database::dao::video_generation_task_dao::VideoGenerationTask; +use proxycast_services::video_generation_service::{ + CreateVideoGenerationRequest, VideoGenerationService, +}; + +static VIDEO_GENERATION_SERVICE: Lazy = + Lazy::new(VideoGenerationService::new); + +/// 获取视频任务请求参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetVideoTaskRequest { + pub task_id: String, + pub refresh_status: Option, +} + +/// 列表视频任务请求参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListVideoTasksRequest { + pub project_id: String, + pub limit: Option, +} + +/// 取消视频任务请求参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelVideoTaskRequest { + pub task_id: String, +} + +#[tauri::command] +pub async fn create_video_generation_task( + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + request: CreateVideoGenerationRequest, +) -> Result { + VIDEO_GENERATION_SERVICE + .create_task(&db, &api_key_provider_service.0, request) + .await +} + +#[tauri::command] +pub async fn get_video_generation_task( + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + request: GetVideoTaskRequest, +) -> Result, String> { + VIDEO_GENERATION_SERVICE + .get_task( + &db, + &api_key_provider_service.0, + &request.task_id, + request.refresh_status.unwrap_or(true), + ) + .await +} + +#[tauri::command] +pub fn list_video_generation_tasks( + db: State<'_, DbConnection>, + request: ListVideoTasksRequest, +) -> Result, String> { + VIDEO_GENERATION_SERVICE.list_tasks( + &db, + &request.project_id, + request.limit.unwrap_or(50).clamp(1, 200), + ) +} + +#[tauri::command] +pub async fn cancel_video_generation_task( + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + request: CancelVideoTaskRequest, +) -> Result, String> { + VIDEO_GENERATION_SERVICE + .cancel_task(&db, &api_key_provider_service.0, &request.task_id) + .await +} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 239ec42be..9a2855a67 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -266,6 +266,14 @@ const MAIN_MENU_ITEMS: SidebarNavItem[] = [ params: { theme: "general", lockTheme: false }, isActive: (currentPage) => currentPage === "agent", }, + { + id: "video", + label: "视频", + icon: Video, + page: getThemeWorkspacePage("video"), + params: { workspaceViewMode: "workspace" }, + isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"), + }, { id: "image-gen", label: "绘画", icon: Image, page: "image-gen" }, { id: "batch", label: "批量任务", icon: Layers, page: "batch" }, { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, @@ -365,7 +373,12 @@ const FOOTER_MENU_ITEMS: SidebarNavItem[] = [ }, ]; -const DEFAULT_ENABLED_NAV_ITEMS = ["home-general", "image-gen", "plugins"]; +const DEFAULT_ENABLED_NAV_ITEMS = [ + "home-general", + "video", + "image-gen", + "plugins", +]; function getIconByName(iconName: string): LucideIcon { const IconComponent = ( @@ -391,6 +404,14 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { const [enabledNavItems, setEnabledNavItems] = useState( DEFAULT_ENABLED_NAV_ITEMS, ); + const [enabledThemes, setEnabledThemes] = useState([ + "general", + "social-media", + "poster", + "music", + "video", + "novel", + ]); const [sidebarPlugins, setSidebarPlugins] = useState([]); const [refreshTrigger, setRefreshTrigger] = useState(0); const [_activeThemeKey, setActiveThemeKey] = useState( @@ -413,21 +434,28 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { } else { setEnabledNavItems(DEFAULT_ENABLED_NAV_ITEMS); } + + const savedThemes = config.content_creator?.enabled_themes; + if (savedThemes && savedThemes.length > 0) { + setEnabledThemes(savedThemes); + } } catch (error) { - console.error("加载导航配置失败:", error); + console.error("加载配置失败:", error); } }; loadNavConfig(); - const handleNavConfigChange = () => { + const handleConfigChange = () => { loadNavConfig(); }; - window.addEventListener("nav-config-changed", handleNavConfigChange); + window.addEventListener("nav-config-changed", handleConfigChange); + window.addEventListener("theme-config-changed", handleConfigChange); return () => { - window.removeEventListener("nav-config-changed", handleNavConfigChange); + window.removeEventListener("nav-config-changed", handleConfigChange); + window.removeEventListener("theme-config-changed", handleConfigChange); }; }, []); @@ -435,6 +463,14 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { return MAIN_MENU_ITEMS.filter((item) => enabledNavItems.includes(item.id)); }, [enabledNavItems]); + const filteredThemeMenuItems = useMemo(() => { + return THEME_MENU_ITEMS.filter((item) => { + // 从 theme-xxx 提取出 xxx + const themeId = item.id.replace("theme-", ""); + return enabledThemes.includes(themeId); + }); + }, [enabledThemes]); + useEffect(() => { const loadSidebarPlugins = async () => { try { @@ -527,8 +563,10 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { ? buildHomeAgentParams(item.params as AgentPageParams | undefined) : isThemeWorkspacePage(item.page) ? buildWorkspaceResetParams( - item.params as AgentPageParams | undefined, - ) + item.params as AgentPageParams | undefined, + (item.params as AgentPageParams | undefined)?.workspaceViewMode ?? + "project-management", + ) : item.params; onNavigate(item.page, params); @@ -569,7 +607,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
创作主题 - {THEME_MENU_ITEMS.map((item) => ( + {filteredThemeMenuItems.map((item) => ( void; onManageProviders?: () => void; + hasCanvasContent?: boolean; + hasContentId?: boolean; + selectedText?: string; } const ENTRY_THEME_ID = "social-media"; @@ -431,134 +437,6 @@ const CREATION_THEMES = [ "novel", ]; -/** - * 推荐内容配置 - * 格式: [简化标题, 完整 Prompt] - * 简化标题用于显示,完整 Prompt 用于点击发送 - */ -const THEME_RECOMMENDATIONS: Record = { - "social-media": [ - [ - "爆款标题生成", - "帮我为'春季护肤routine'写10个小红书爆款标题,要求:数字开头、制造悬念、引发共鸣", - ], - [ - "小红书探店文案", - "写一篇小红书探店文案:周末在杭州发现一家宝藏咖啡店,工业风装修+拉花拿铁,适合拍照出片", - ], - [ - "公众号排版", - "帮我把这段话排版成公众号风格:每段不超过150字,加入小标题和emoji,重点内容加粗", - ], - [ - "评论区回复", - "用户评论'这个产品真的好用吗?还是广告?',帮我写一条真诚、有说服力的回复", - ], - ], - poster: [ - [ - "海报设计", - "设计一张夏日音乐节海报:主色调渐变蓝紫,中央是剪影吉他和声波元素,底部大标题'夏日音浪'", - ], - [ - "插画生成", - "生成一幅温馨的卧室插画:暖色调,落地窗透进阳光,书桌上有绿植和笔记本,治愈系风格", - ], - [ - "UI 界面", - "设计一个健身APP首页:深色模式,顶部显示今日步数,中间是环形进度条,底部四个功能入口", - ], - [ - "Logo 设计", - "设计一家名为'绿野'的有机食品品牌Logo:简约绿色叶子轮廓,可单独使用,适合多种尺寸", - ], - [ - "摄影修图", - "人像照片调色建议:肤色通透,背景偏暖,整体日系清新风格,降低对比度提升亮度", - ], - ], - knowledge: [ - [ - "解释量子计算", - "用通俗易懂的方式解释量子计算是什么,类比成生活中的例子,适合非理科背景的人理解", - ], - [ - "总结这篇论文", - "[粘贴论文链接或内容后] 帮我总结这篇论文的核心观点、研究方法和主要结论,输出500字以内的摘要", - ], - [ - "如何制定OKR", - "详细介绍OKR(目标与关键结果)制定方法,包括设定原则、常见误区和实际案例,适合团队管理者", - ], - [ - "分析行业趋势", - "分析2024年AI行业发展趋势,从技术突破、商业化进程、监管政策三个维度展开", - ], - ], - planning: [ - [ - "日本旅行计划", - "帮我制定一个7天日本关西旅行计划:大阪进京都出,包含主要景点、美食推荐、交通路线和预算估算", - ], - [ - "年度职业规划", - "制定一名前端开发工程师的2024年职业规划:技能提升、项目经验、人脉积累、求职目标四个维度", - ], - [ - "婚礼流程表", - "制定一场户外草坪婚礼的流程表:上午10点开始,包含仪式、宴会、互动环节,标注每个环节的时间", - ], - [ - "健身计划", - "为办公室上班族制定健身计划:每周3次,每次30分钟,无需器械,可在办公室或家中完成", - ], - ], - music: [ - [ - "流行情歌", - "创作一首关于'暗恋'的流行情歌:主歌描述图书馆偶遇,副歌表达不敢告白的纠结,温柔的R&B风格", - ], - [ - "古风歌词", - "创作古风歌词:主题是'江湖离别',意象包括酒、剑、残阳、孤舟,五言句式为主,押韵工整", - ], - [ - "说唱歌词", - "创作一段励志说唱:主题是'逆风翻盘',讲述从低谷到成功的经历,快节奏,押韵密集,副歌要炸", - ], - [ - "儿歌创作", - "创作一首儿童安全教育儿歌:主题是'过马路要小心',简单易记,欢快活泼,3-5岁儿童能跟着唱", - ], - [ - "旋律学习", - "帮我分析《稻香》的旋律特点:调式、和弦进行、节奏型,以及为什么听起来很怀旧温暖", - ], - ], - novel: [ - [ - "玄幻小说", - "创作玄幻小说开篇:主角在深山古洞觉醒传承,获得上古剑诀,第一章包含世界观铺垫和悬念设置", - ], - [ - "都市言情", - "创作都市言情小说开篇:职场新人与高冷上司因工作误会相识,第一章突出女主性格和两人的初次冲突", - ], - [ - "悬疑推理", - "创作悬疑推理小说开篇:雨夜发生密室杀人案,侦探到达现场发现三条线索,第一章制造悬念和推理伏笔", - ], - [ - "科幻未来", - "创作科幻小说开篇:2084年人类首次接触外星文明,主角作为语言学家被召唤,第一章描写接触场景和紧张氛围", - ], - [ - "历史架空", - "创作历史架空小说开篇:三国时期,一个现代人穿越成普通士兵,如何利用现代知识在乱世中生存", - ], - ], -}; - // 主题对应的图标 const THEME_ICONS: Record = { "social-media": "✨", @@ -625,36 +503,49 @@ export const EmptyState: React.FC = ({ executionStrategy = "react", setExecutionStrategy, onManageProviders, + hasCanvasContent = false, + hasContentId = false, + selectedText = "", }) => { // 从配置中读取启用的主题 const [enabledThemes, setEnabledThemes] = useState( DEFAULT_ENABLED_THEMES, ); + const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] = + useState(true); // 加载配置 useEffect(() => { - const loadEnabledThemes = async () => { + const loadConfigPreferences = async () => { try { const config = await getConfig(); if (config.content_creator?.enabled_themes) { setEnabledThemes(config.content_creator.enabled_themes); } + setAppendSelectedTextToRecommendation( + config.chat_appearance?.append_selected_text_to_recommendation ?? true, + ); } catch (e) { console.error("加载主题配置失败:", e); } }; - loadEnabledThemes(); + loadConfigPreferences(); - // 监听主题配置变更事件 - const handleThemeConfigChange = () => { - loadEnabledThemes(); + // 监听配置变更事件 + const handleConfigChange = () => { + loadConfigPreferences(); }; - window.addEventListener("theme-config-changed", handleThemeConfigChange); + window.addEventListener("theme-config-changed", handleConfigChange); + window.addEventListener( + "chat-appearance-config-changed", + handleConfigChange, + ); return () => { + window.removeEventListener("theme-config-changed", handleConfigChange); window.removeEventListener( - "theme-config-changed", - handleThemeConfigChange, + "chat-appearance-config-changed", + handleConfigChange, ); }; }, []); @@ -714,12 +605,44 @@ export const EmptyState: React.FC = ({ [entryTaskType, entrySlotValues], ); + const recommendationSelectedText = appendSelectedTextToRecommendation + ? selectedText + : ""; + const currentRecommendations = useMemo(() => { - if (isEntryTheme) { - return getEntryTaskRecommendations(entryTaskType); + return getContextualRecommendations({ + activeTheme, + input, + creationMode, + entryTaskType, + platform, + hasCanvasContent, + hasContentId, + selectedText: recommendationSelectedText, + }); + }, [ + activeTheme, + input, + creationMode, + entryTaskType, + platform, + hasCanvasContent, + hasContentId, + recommendationSelectedText, + ]); + + const selectedTextPreview = useMemo(() => { + const normalized = (recommendationSelectedText || "") + .trim() + .replace(/\s+/g, " "); + if (!normalized) { + return ""; } - return THEME_RECOMMENDATIONS[activeTheme] || []; - }, [activeTheme, entryTaskType, isEntryTheme]); + + return normalized.length > 56 + ? `${normalized.slice(0, 56).trim()}…` + : normalized; + }, [recommendationSelectedText]); const handleEntrySlotChange = (key: string, value: string) => { setEntrySlotValues((prev) => ({ @@ -1265,6 +1188,12 @@ export const EmptyState: React.FC = ({ {/* Dynamic Inspiration/Tips based on Tab - Styled nicely */} + {selectedTextPreview && ( +
+ 已检测到选中内容,点击推荐会自动附带上下文: + “{selectedTextPreview}” +
+ )}
{currentRecommendations.map(([shortLabel, fullPrompt]) => ( = ({ className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors" title={fullPrompt} onClick={() => { + const promptWithSelection = buildRecommendationPrompt( + fullPrompt, + selectedText, + appendSelectedTextToRecommendation, + ); if (onRecommendationClick) { - onRecommendationClick(shortLabel, fullPrompt); + onRecommendationClick(shortLabel, promptWithSelection); } else { - setInput(fullPrompt); + setInput(promptWithSelection); } }} > diff --git a/src/components/agent/chat/components/MarkdownRenderer.tsx b/src/components/agent/chat/components/MarkdownRenderer.tsx index 6f5b52fad..84409b0a6 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.tsx @@ -407,7 +407,13 @@ export const MarkdownRenderer: React.FC = memo( const childProps = child.props as any; const className = childProps?.className || ""; const match = /language-(\w+)/.exec(className); - const language = match ? match[1] : ""; + const language = match ? match[1] : "text"; + const codeChildren = childProps?.children; + const codeContent = String( + Array.isArray(codeChildren) + ? codeChildren.join("") + : codeChildren || "", + ).replace(/\n$/, ""); // 调试:输出检测到的语言 if (language) { @@ -419,14 +425,6 @@ export const MarkdownRenderer: React.FC = memo( // 如果是 a2ui 代码块,特殊处理 if (language === "a2ui") { - // 获取代码内容 - children 可能是字符串或数组 - const codeChildren = childProps?.children; - const codeContent = String( - Array.isArray(codeChildren) - ? codeChildren.join("") - : codeChildren || "", - ).replace(/\n$/, ""); - console.log( "[MarkdownRenderer] a2ui 代码块内容长度:", codeContent.length, @@ -459,28 +457,6 @@ export const MarkdownRenderer: React.FC = memo( } } - // 其他代码块正常渲染 - return
{children}
; - }, - code({ inline, className, children, ...props }: any) { - const match = /language-(\w+)/.exec(className || ""); - const codeContent = String(children).replace(/\n$/, ""); - const language = match ? match[1] : "text"; - - // Inline code - if (inline) { - return ( - - {children} - - ); - } - - // a2ui 已在 pre 组件中处理,这里跳过 - if (language === "a2ui") { - return null; - } - // 如果启用了代码块折叠,显示占位符卡片 if (collapseCodeBlocks) { const lineCount = codeContent.split("\n").length; @@ -516,13 +492,29 @@ export const MarkdownRenderer: React.FC = memo( background: "transparent", fontSize: "13px", }} - {...props} > {codeContent} ); }, + code({ inline, className, children, ...props }: any) { + // Inline code + if (inline) { + return ( + + {children} + + ); + } + + // 非 inline code 统一由 pre 组件处理,避免块级元素落入

+ return ( + + {children} + + ); + }, // 普通图片渲染(非 base64) img({ src, alt, ...props }: any) { // base64 图片已经在上面单独处理了,这里只处理普通 URL 图片 @@ -537,15 +529,13 @@ export const MarkdownRenderer: React.FC = memo( }; return ( - - - + ); }, }} diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index b96743acf..4fdd6bdff 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -1488,6 +1488,26 @@ describe("useAsterAgentChat 兼容接口", () => { } }); + it("triggerAIGuide 传入引导词时应发送该引导词", async () => { + const harness = mountHook("ws-guide-social"); + const prompt = "请先确认社媒平台和目标受众。"; + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().triggerAIGuide(prompt); + }); + + const value = harness.getValue(); + expect(value.messages).toHaveLength(1); + expect(value.messages[0]?.role).toBe("assistant"); + expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1); + expect(mockSendAsterMessageStream.mock.calls[0]?.[0]).toBe(prompt); + } finally { + harness.unmount(); + } + }); + it("renameTopic 应调用后端并刷新话题标题", async () => { const createdAt = Math.floor(Date.now() / 1000); mockListAsterSessions diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index 6c1c48240..4c7f04aae 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -1940,9 +1940,12 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ); // 兼容 Native 接口:触发 AI 引导(仅生成助手消息,不注入用户气泡) - const triggerAIGuide = useCallback(async () => { - await sendMessage("", [], false, false, true); - }, [sendMessage]); + const triggerAIGuide = useCallback( + async (initialPrompt?: string) => { + await sendMessage(initialPrompt?.trim() || "", [], false, false, true); + }, + [sendMessage], + ); // 清空消息(兼容 useAgentChat 的可选参数) const clearMessages = useCallback( diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 8c05613e1..49c37e742 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -14,6 +14,9 @@ const { mockArtifactsAtom, mockSelectedArtifactAtom, mockSelectedArtifactIdAtom, + mockGenerateContentCreationPrompt, + mockIsContentCreationTheme, + mockEmptyState, } = vi.hoisted(() => ({ mockUseAgentChatUnified: vi.fn(), mockGetProject: vi.fn(), @@ -31,6 +34,11 @@ const { mockArtifactsAtom: { key: "artifacts" }, mockSelectedArtifactAtom: { key: "selectedArtifact" }, mockSelectedArtifactIdAtom: { key: "selectedArtifactId" }, + mockGenerateContentCreationPrompt: vi.fn(() => "mock-system-prompt"), + mockIsContentCreationTheme: vi.fn(() => false), + mockEmptyState: vi.fn((props?: { input?: string }) => ( +

{props?.input || ""}
+ )), })); vi.mock("sonner", () => ({ @@ -132,7 +140,7 @@ vi.mock("./components/Inputbar", () => ({ })); vi.mock("./components/EmptyState", () => ({ - EmptyState: () =>
, + EmptyState: (props?: { input?: string }) => mockEmptyState(props), })); vi.mock("@/components/content-creator/core/StepGuide/StepProgress", () => ({ @@ -165,8 +173,8 @@ vi.mock("jotai", () => ({ })); vi.mock("@/components/content-creator/utils/systemPrompt", () => ({ - generateContentCreationPrompt: vi.fn(() => "mock-system-prompt"), - isContentCreationTheme: vi.fn(() => false), + generateContentCreationPrompt: mockGenerateContentCreationPrompt, + isContentCreationTheme: mockIsContentCreationTheme, })); vi.mock("@/components/content-creator/utils/projectPrompt", () => ({ @@ -215,6 +223,9 @@ interface MountedHarness { const mountedRoots: MountedHarness[] = []; const observedWorkspaceIds: string[] = []; +let sharedSwitchTopicMock: ReturnType; +let sharedSendMessageMock: ReturnType; +let sharedTriggerAIGuideMock: ReturnType; function createProject(id: string, archived = false) { return { @@ -301,8 +312,15 @@ beforeEach(() => { mockGetContent.mockResolvedValue(null); mockUpdateContent.mockResolvedValue(undefined); mockGetProjectMemory.mockResolvedValue(null); + mockGenerateContentCreationPrompt.mockReturnValue("mock-system-prompt"); + mockIsContentCreationTheme.mockReturnValue(false); + mockEmptyState.mockImplementation((props?: { input?: string }) => ( +
{props?.input || ""}
+ )); - const mockOriginalSwitchTopic = vi.fn(async () => undefined); + sharedSwitchTopicMock = vi.fn(async () => undefined); + sharedSendMessageMock = vi.fn(async () => undefined); + sharedTriggerAIGuideMock = vi.fn(); mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { observedWorkspaceIds.push(workspaceId); @@ -315,13 +333,13 @@ beforeEach(() => { setExecutionStrategy: vi.fn(), messages: [], isSending: false, - sendMessage: vi.fn(async () => undefined), + sendMessage: sharedSendMessageMock, stopSending: vi.fn(async () => undefined), clearMessages: vi.fn(), deleteMessage: vi.fn(), editMessage: vi.fn(), handlePermissionResponse: vi.fn(), - triggerAIGuide: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, topics: [ { id: "topic-a", @@ -330,7 +348,7 @@ beforeEach(() => { }, ], sessionId: "session-1", - switchTopic: mockOriginalSwitchTopic, + switchTopic: sharedSwitchTopicMock, deleteTopic: vi.fn(), renameTopic: vi.fn(), }; @@ -443,3 +461,63 @@ describe("AgentChatPage 话题切换项目恢复", () => { ); }); }); + +describe("AgentChatPage 自动引导", () => { + it("社媒空文稿应预填引导词且不自动发送", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + + const container = renderPage({ + projectId: "project-social", + contentId: "content-social", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(10); + + expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain("社媒内容创作教练"); + }); + + it("非社媒空文稿应维持原始自动引导调用", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + + renderPage({ + projectId: "project-document", + contentId: "content-document", + theme: "document", + lockTheme: true, + }); + await flushEffects(10); + + expect(sharedTriggerAIGuideMock).toHaveBeenCalledTimes(1); + expect(sharedTriggerAIGuideMock).toHaveBeenCalledWith(); + }); + + it("存在 initialUserPrompt 时应优先发送首条意图", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + const onInitialUserPromptConsumed = vi.fn(); + const initialUserPrompt = "请先帮我写一篇社媒文案提纲。"; + + renderPage({ + projectId: "project-social-intent", + contentId: "content-social-intent", + theme: "social-media", + lockTheme: true, + initialUserPrompt, + onInitialUserPromptConsumed, + }); + await flushEffects(12); + + expect(sharedSendMessageMock).toHaveBeenCalledWith( + initialUserPrompt, + [], + false, + false, + false, + undefined, + ); + expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); + expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index a18f0f265..48c538367 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -80,6 +80,7 @@ import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; import { getFileToStepMap } from "./utils/workflowMapping"; import { normalizeProjectId } from "./utils/topicProjectResolution"; import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch"; +import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "general", @@ -280,6 +281,7 @@ export function AgentChatPage({ }) { const [showSidebar, setShowSidebar] = useState(false); const [input, setInput] = useState(""); + const [selectedText, setSelectedText] = useState(""); // 内容创作相关状态 const [activeTheme, setActiveTheme] = useState( @@ -998,6 +1000,7 @@ export function AgentChatPage({ const handleClearMessages = useCallback(() => { clearMessages(); setInput(""); + setSelectedText(""); // 重置布局模式 setLayoutMode("chat"); // 恢复侧边栏显示 @@ -1028,6 +1031,7 @@ export function AgentChatPage({ showToast: false, }); setInput(""); + setSelectedText(""); setLayoutMode("chat"); setShowSidebar(true); setCanvasState(null); @@ -1062,6 +1066,7 @@ export function AgentChatPage({ showToast: false, }); setInput(""); + setSelectedText(""); setLayoutMode("chat"); setShowSidebar(true); setCanvasState(null); @@ -1082,6 +1087,16 @@ export function AgentChatPage({ // 当开始对话时自动折叠侧边栏 const hasMessages = messages.length > 0; + const handleCanvasSelectionTextChange = useCallback((text: string) => { + const normalized = text.trim().replace(/\s+/g, " "); + const nextValue = normalized.length > 500 ? normalized.slice(0, 500) : normalized; + setSelectedText((previous) => (previous === nextValue ? previous : nextValue)); + }, []); + + useEffect(() => { + setSelectedText(""); + }, [activeTheme, contentId]); + useEffect(() => { if (!canvasState || canvasState.type !== "novel") { setNovelChapterListCollapsed(false); @@ -1743,10 +1758,23 @@ export function AgentChatPage({ return; } + const defaultGuidePrompt = getDefaultGuidePromptByTheme(activeTheme); + if (defaultGuidePrompt) { + console.log("[AgentChatPage] 自动预填主题引导词"); + setInput((previous) => { + if (previous.trim()) { + return previous; + } + return defaultGuidePrompt; + }); + return; + } + console.log("[AgentChatPage] 自动触发 AI 创作引导"); triggerAIGuideRef.current(); } }, [ + activeTheme, contentId, messages.length, project, @@ -1880,6 +1908,13 @@ export function AgentChatPage({ } }} showThemeTabs={false} + hasCanvasContent={ + activeTheme === "general" + ? Boolean(generalCanvasState.content?.trim()) + : !isCanvasStateEmpty(canvasState) + } + hasContentId={Boolean(contentId)} + selectedText={selectedText} onRecommendationClick={(shortLabel, fullPrompt) => { // 直接将推荐提示词放入输入框,不创建项目 setInput(fullPrompt); @@ -1985,6 +2020,7 @@ export function AgentChatPage({ onStateChange={setCanvasState} onClose={handleCloseCanvas} isStreaming={isSending} + onSelectionTextChange={handleCanvasSelectionTextChange} novelControls={ canvasState.type === "novel" ? { @@ -2007,6 +2043,7 @@ export function AgentChatPage({ mappedTheme, handleCloseCanvas, isSending, + handleCanvasSelectionTextChange, artifactViewMode, artifactPreviewSize, novelChapterListCollapsed, diff --git a/src/components/agent/chat/utils/contextualRecommendations.test.ts b/src/components/agent/chat/utils/contextualRecommendations.test.ts new file mode 100644 index 000000000..e606b6699 --- /dev/null +++ b/src/components/agent/chat/utils/contextualRecommendations.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRecommendationPrompt, + getContextualRecommendations, +} from "./contextualRecommendations"; + +describe("getContextualRecommendations", () => { + it("社媒空白场景应返回起稿类推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "social-media", + input: "", + creationMode: "guided", + entryTaskType: "direct", + platform: "xiaohongshu", + hasCanvasContent: false, + hasContentId: true, + selectedText: "", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[0]).toContain("选题"); + }); + + it("社媒有正文时应优先返回改写类推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "social-media", + input: "", + creationMode: "hybrid", + entryTaskType: "rewrite", + platform: "wechat", + hasCanvasContent: true, + hasContentId: true, + selectedText: "", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[0]).toContain("润色"); + }); + + it("社媒有输入时应返回输入相关推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "social-media", + input: "春季敏感肌修护", + creationMode: "fast", + entryTaskType: "direct", + platform: "xiaohongshu", + hasCanvasContent: false, + hasContentId: false, + selectedText: "", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[1]).toContain("春季敏感肌修护"); + }); + + it("非社媒主题应走主题兜底推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "planning", + input: "", + creationMode: "guided", + entryTaskType: "direct", + platform: "xiaohongshu", + hasCanvasContent: false, + hasContentId: false, + selectedText: "", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[0]).toContain("计划"); + }); + + it("通用主题应返回通用对话推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "general", + input: "", + creationMode: "guided", + entryTaskType: "direct", + platform: "xiaohongshu", + hasCanvasContent: false, + hasContentId: false, + selectedText: "", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[0]).toContain("需求"); + }); + + it("文档主题应返回办公文档推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "document", + input: "", + creationMode: "guided", + entryTaskType: "direct", + platform: "xiaohongshu", + hasCanvasContent: false, + hasContentId: false, + selectedText: "", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[0]).toContain("公文"); + }); + + it("社媒有选中文本时应优先返回选区改写推荐", () => { + const recommendations = getContextualRecommendations({ + activeTheme: "social-media", + input: "", + creationMode: "guided", + entryTaskType: "rewrite", + platform: "wechat", + hasCanvasContent: true, + hasContentId: true, + selectedText: "这是一段待优化的原文内容。", + }); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations[0]?.[0]).toContain("选中"); + }); + + it("构建推荐提示词时应注入选中文本上下文", () => { + const prompt = buildRecommendationPrompt("请帮我改写内容。", "这是原文。"); + expect(prompt).toContain("请帮我改写内容。"); + expect(prompt).toContain("[参考选中内容]"); + expect(prompt).toContain("这是原文。"); + }); + + it("无选中文本时应保持原始提示词", () => { + const prompt = buildRecommendationPrompt("请帮我润色。", ""); + expect(prompt).toBe("请帮我润色。"); + }); + + it("选中文本过长时应截断注入", () => { + const longSelectedText = "a".repeat(380); + const prompt = buildRecommendationPrompt("请总结。", longSelectedText); + expect(prompt).toContain("[参考选中内容]"); + expect(prompt).toContain("…"); + }); + + it("关闭附带选区开关时应忽略选中文本", () => { + const prompt = buildRecommendationPrompt( + "请润色文稿。", + "这是一段选中的文稿内容。", + false, + ); + expect(prompt).toBe("请润色文稿。"); + }); +}); diff --git a/src/components/agent/chat/utils/contextualRecommendations.ts b/src/components/agent/chat/utils/contextualRecommendations.ts new file mode 100644 index 000000000..6b241b59c --- /dev/null +++ b/src/components/agent/chat/utils/contextualRecommendations.ts @@ -0,0 +1,338 @@ +import type { CreationMode, EntryTaskType } from "../components/types"; +import { getEntryTaskRecommendations } from "./entryPromptComposer"; + +export type RecommendationTuple = [string, string]; +const SELECTED_TEXT_MAX_LENGTH = 320; + +interface RecommendationContext { + activeTheme: string; + input: string; + creationMode: CreationMode; + entryTaskType: EntryTaskType; + platform: string; + hasCanvasContent: boolean; + hasContentId: boolean; + selectedText?: string; +} + +const SOCIAL_PLATFORM_LABELS: Record = { + xiaohongshu: "小红书", + wechat: "公众号", + zhihu: "知乎", + toutiao: "头条", + juejin: "掘金", + csdn: "CSDN", +}; + +const FALLBACK_THEME_RECOMMENDATIONS: Record = { + general: [ + [ + "需求澄清助手", + "请先帮我澄清当前问题:目标是什么、已知条件是什么、缺失信息是什么,并给出下一步提问清单。", + ], + [ + "方案对比", + "围绕这个问题给我 3 套可执行方案,分别说明优缺点、适用场景和实施成本。", + ], + [ + "快速总结", + "请把这件事总结成“背景-问题-建议-行动”四段结构,控制在 200 字内。", + ], + [ + "行动清单", + "请把目标拆成可执行 TODO 列表:按优先级排序,给出预计耗时和验收标准。", + ], + ], + "social-media": [ + [ + "爆款标题生成", + "帮我为“春季护肤routine”写10个小红书爆款标题,要求:数字开头、制造悬念、引发共鸣。", + ], + [ + "小红书探店文案", + "写一篇小红书探店文案:周末在杭州发现一家宝藏咖啡店,工业风装修+拉花拿铁,适合拍照出片。", + ], + [ + "公众号排版", + "帮我把这段话排版成公众号风格:每段不超过150字,加入小标题和 emoji,重点内容加粗。", + ], + [ + "评论区回复", + "用户评论“这个产品真的好用吗?还是广告?”,帮我写一条真诚、有说服力的回复。", + ], + ], + poster: [ + [ + "海报设计", + "设计一张夏日音乐节海报:主色调渐变蓝紫,中央是剪影吉他和声波元素,底部大标题“夏日音浪”。", + ], + [ + "插画生成", + "生成一幅温馨的卧室插画:暖色调,落地窗透进阳光,书桌上有绿植和笔记本,治愈系风格。", + ], + [ + "UI 界面", + "设计一个健身APP首页:深色模式,顶部显示今日步数,中间是环形进度条,底部四个功能入口。", + ], + [ + "Logo 设计", + "设计一家名为“绿野”的有机食品品牌 Logo:简约绿色叶子轮廓,可单独使用,适合多种尺寸。", + ], + [ + "摄影修图", + "人像照片调色建议:肤色通透,背景偏暖,整体日系清新风格,降低对比度提升亮度。", + ], + ], + knowledge: [ + [ + "解释量子计算", + "用通俗易懂的方式解释量子计算是什么,类比成生活中的例子,适合非理科背景的人理解。", + ], + [ + "总结这篇论文", + "帮我总结这篇论文的核心观点、研究方法和主要结论,输出 500 字以内摘要。", + ], + [ + "如何制定OKR", + "详细介绍 OKR 制定方法,包括设定原则、常见误区和实际案例,适合团队管理者。", + ], + [ + "分析行业趋势", + "分析 2024 年 AI 行业发展趋势,从技术突破、商业化进程、监管政策三个维度展开。", + ], + ], + planning: [ + [ + "日本旅行计划", + "帮我制定一个 7 天日本关西旅行计划:大阪进京都出,包含景点、美食、交通路线和预算估算。", + ], + [ + "年度职业规划", + "制定一名前端开发工程师的年度职业规划:技能提升、项目经验、人脉积累、求职目标四个维度。", + ], + [ + "婚礼流程表", + "制定一场户外草坪婚礼流程:上午 10 点开始,包含仪式、宴会、互动环节,并标注每个环节时间。", + ], + [ + "健身计划", + "为办公室上班族制定健身计划:每周 3 次,每次 30 分钟,无需器械,可在办公室或家中完成。", + ], + ], + music: [ + [ + "流行情歌", + "创作一首关于“暗恋”的流行情歌:主歌描述图书馆偶遇,副歌表达不敢告白的纠结,温柔 R&B 风格。", + ], + [ + "古风歌词", + "创作古风歌词:主题“江湖离别”,意象包括酒、剑、残阳、孤舟,五言句式为主,押韵工整。", + ], + [ + "说唱歌词", + "创作一段励志说唱:主题“逆风翻盘”,讲述从低谷到成功的经历,快节奏、押韵密集,副歌要炸。", + ], + [ + "儿歌创作", + "创作一首儿童安全教育儿歌:主题“过马路要小心”,简单易记,欢快活泼,3-5 岁儿童可跟唱。", + ], + [ + "旋律学习", + "分析《稻香》的旋律特点:调式、和弦进行、节奏型,以及为什么听起来怀旧温暖。", + ], + ], + novel: [ + [ + "玄幻小说", + "创作玄幻小说开篇:主角在深山古洞觉醒传承,获得上古剑诀,第一章含世界观铺垫与悬念设置。", + ], + [ + "都市言情", + "创作都市言情开篇:职场新人与高冷上司因工作误会相识,第一章突出女主性格与初次冲突。", + ], + [ + "悬疑推理", + "创作悬疑推理开篇:雨夜发生密室杀人案,侦探到场发现三条线索,第一章制造悬念与推理伏笔。", + ], + [ + "科幻未来", + "创作科幻小说开篇:2084 年人类首次接触外星文明,主角作为语言学家被召唤,描写接触场景与紧张氛围。", + ], + [ + "历史架空", + "创作历史架空开篇:三国时期,一个现代人穿越成普通士兵,如何利用现代知识在乱世中生存。", + ], + ], + document: [ + [ + "公文式润色", + "请把当前内容改写成正式办公文档风格,要求语句简洁、结构清晰、术语统一。", + ], + [ + "会议纪要整理", + "请把内容整理成会议纪要:议题、讨论要点、结论、责任人、截止时间。", + ], + [ + "汇报提纲", + "请基于当前主题生成一份工作汇报提纲:背景、进展、风险、下一步计划。", + ], + [ + "邮件草稿", + "请生成一封专业邮件草稿:说明背景、核心诉求、希望对方的下一步动作。", + ], + ], + video: [ + [ + "短视频脚本", + "请为这个主题写一条 60 秒短视频脚本,结构为“开场钩子-冲突-解决-行动号召”。", + ], + [ + "分镜清单", + "请把内容拆成 8-10 个镜头分镜,包含画面描述、旁白、时长和转场建议。", + ], + [ + "口播优化", + "请将当前文案改成自然口播稿,句子更短、更有节奏,并保留关键信息。", + ], + [ + "标题与封面", + "请给我 10 个短视频标题和 5 个封面文案,要求突出冲突与收益点。", + ], + ], +}; + +const SOCIAL_BLANK_RECOMMENDATIONS: RecommendationTuple[] = [ + [ + "从选题开始", + "请先帮我做社媒选题:给我 5 个可执行且有传播潜力的选题,并说明各自目标受众与切入角度。", + ], + [ + "先搭结构", + "先不要写正文,请先给我“标题-开头-主体-结尾-互动引导”的内容结构框架。", + ], + [ + "平台差异建议", + "同一主题下,小红书、公众号、知乎的写法差异是什么?请给我一份可执行对照清单。", + ], +]; + +const SOCIAL_REWRITE_RECOMMENDATIONS: RecommendationTuple[] = [ + [ + "正文润色提效", + "请帮我润色当前文稿,保持核心观点不变,增强可读性和节奏感,并标注关键修改点。", + ], + [ + "结构压缩重排", + "请把当前文稿重排成“问题-观点-方法-案例-行动”结构,删掉重复表达。", + ], + [ + "平台适配改写", + "请基于当前文稿输出三个版本:小红书版、公众号版、知乎版,保留事实信息,语气与结构各自适配。", + ], +]; + +function normalizeSubject(value: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) { + return "这个主题"; + } + + return normalized.length > 24 + ? `${normalized.slice(0, 24).trim()}...` + : normalized; +} + +function normalizePlatform(value: string): string { + return SOCIAL_PLATFORM_LABELS[value] || "社媒平台"; +} + +function buildSocialRecommendations( + context: RecommendationContext, +): RecommendationTuple[] { + const selectedText = (context.selectedText || "").trim(); + if (selectedText) { + return [ + [ + "按选中内容改写", + "请基于我选中的段落做三版改写:精简版、增强感染力版、专业理性版,并解释适用场景。", + ], + [ + "选中段落提炼", + "请提炼我选中段落的核心观点,并改成“可直接发布”的社媒表达,控制在 120 字内。", + ], + [ + "选中段落转风格", + "请把我选中的内容分别改成小红书口语风和公众号深度风,保留事实,不改变结论。", + ], + ]; + } + + if (context.hasCanvasContent) { + return SOCIAL_REWRITE_RECOMMENDATIONS; + } + + const normalizedInput = context.input.trim(); + if (normalizedInput) { + const subject = normalizeSubject(normalizedInput); + const platform = normalizePlatform(context.platform); + return [ + [ + "补全创作简报", + `基于“${subject}”,请先补全一份社媒创作简报:目标受众、核心卖点、内容结构、语气风格、互动引导。`, + ], + [ + "直接起 3 个版本", + `围绕“${subject}”,先给我 3 个不同风格的 ${platform} 起稿版本(实用型/故事型/观点型)。`, + ], + [ + "先出标题开头", + `围绕“${subject}”,先输出 10 个标题和 3 个开头钩子,供我选择后再写正文。`, + ], + ]; + } + + if (context.hasContentId || context.creationMode === "guided") { + return SOCIAL_BLANK_RECOMMENDATIONS; + } + + const entryRecommendations = getEntryTaskRecommendations(context.entryTaskType); + if (entryRecommendations.length > 0) { + return entryRecommendations; + } + + return FALLBACK_THEME_RECOMMENDATIONS["social-media"]; +} + +export function getContextualRecommendations( + context: RecommendationContext, +): RecommendationTuple[] { + if (context.activeTheme === "social-media") { + return buildSocialRecommendations(context); + } + + return FALLBACK_THEME_RECOMMENDATIONS[context.activeTheme] || []; +} + +export function buildRecommendationPrompt( + basePrompt: string, + selectedText?: string, + appendSelectedText = true, +): string { + const normalizedPrompt = basePrompt.trim(); + if (!appendSelectedText) { + return normalizedPrompt; + } + + const normalizedSelected = (selectedText || "").trim(); + + if (!normalizedSelected) { + return normalizedPrompt; + } + + const clippedSelected = + normalizedSelected.length > SELECTED_TEXT_MAX_LENGTH + ? `${normalizedSelected.slice(0, SELECTED_TEXT_MAX_LENGTH).trim()}…` + : normalizedSelected; + + return `${normalizedPrompt}\n\n[参考选中内容]\n${clippedSelected}`; +} diff --git a/src/components/agent/chat/utils/defaultGuidePrompt.ts b/src/components/agent/chat/utils/defaultGuidePrompt.ts new file mode 100644 index 000000000..9830db816 --- /dev/null +++ b/src/components/agent/chat/utils/defaultGuidePrompt.ts @@ -0,0 +1,25 @@ +const SOCIAL_MEDIA_DEFAULT_GUIDE_PROMPT = `你现在是社媒内容创作教练,请先进入“提问引导”阶段,不要直接成文。 + +请先用简洁问题逐项确认以下信息: +1. 创作主题(想解决的问题或核心观点) +2. 发布平台(如小红书/公众号/知乎) +3. 目标受众(人群画像) +4. 目标结果(涨粉/互动/转化/品牌认知) +5. 语气风格与篇幅要求 + +提问规则: +- 一次最多 3 个问题,问题要具体可回答 +- 若信息不全,继续追问关键缺失项 +- 在用户明确“可以开始写”前,不输出完整稿件 + +当信息收集完成后,再给出创作执行计划并开始写作。`; + +export function getDefaultGuidePromptByTheme( + theme: string, +): string | undefined { + if (theme === "social-media") { + return SOCIAL_MEDIA_DEFAULT_GUIDE_PROMPT; + } + + return undefined; +} diff --git a/src/components/artifact/canvasAdapterUtils.ts b/src/components/artifact/canvasAdapterUtils.ts index ce88b0651..ce7b90f0a 100644 --- a/src/components/artifact/canvasAdapterUtils.ts +++ b/src/components/artifact/canvasAdapterUtils.ts @@ -17,6 +17,7 @@ import { createInitialPosterState } from "@/components/content-creator/canvas/po import { createInitialMusicState } from "@/components/content-creator/canvas/music"; import { createInitialScriptState } from "@/components/content-creator/canvas/script"; import { createInitialNovelState } from "@/components/content-creator/canvas/novel"; +import { createInitialVideoState } from "@/components/content-creator/canvas/video"; import type { DocumentCanvasState } from "@/components/content-creator/canvas/document/types"; import type { PosterCanvasState } from "@/components/content-creator/canvas/poster/types"; import type { MusicCanvasState } from "@/components/content-creator/canvas/music/types"; @@ -54,6 +55,7 @@ export const ARTIFACT_TO_CANVAS_TYPE: Record = { "canvas:music": "music", "canvas:script": "script", "canvas:novel": "novel", + "canvas:video": "video", }; /** @@ -65,6 +67,7 @@ export const CANVAS_TYPE_LABELS: Record = { music: "音乐", script: "剧本", novel: "小说", + video: "视频", }; /** @@ -76,6 +79,7 @@ export const CANVAS_TYPE_ICONS: Record = { music: "🎵", script: "🎬", novel: "📚", + video: "🎞️", }; // ============================================================================ @@ -145,6 +149,8 @@ export function createCanvasStateFromArtifact( return createInitialScriptState(content); case "novel": return createInitialNovelState(content); + case "video": + return createInitialVideoState(content); default: return null; } diff --git a/src/components/chat/ChatPage.tsx b/src/components/chat/ChatPage.tsx index 6cb1901fd..fabecb64c 100644 --- a/src/components/chat/ChatPage.tsx +++ b/src/components/chat/ChatPage.tsx @@ -4,7 +4,7 @@ * @module components/chat/ChatPage */ -import React, { useState, useCallback, memo } from "react"; +import React, { useState, useCallback, useEffect, memo } from "react"; import styled from "styled-components"; import { MessageList, InputBar, ThemeSelector, EmptyState } from "./components"; import { useChat } from "./hooks"; @@ -56,6 +56,25 @@ export const ChatPage: React.FC = memo(() => { } = useChat(); const [currentTheme, setCurrentTheme] = useState("general"); + const [selectedText, setSelectedText] = useState(""); + + useEffect(() => { + const handleSelectionChange = () => { + const rawSelection = window.getSelection()?.toString() || ""; + const normalized = rawSelection.trim().replace(/\s+/g, " "); + const clipped = + normalized.length > 500 + ? `${normalized.slice(0, 500).trim()}…` + : normalized; + + setSelectedText((prev) => (prev === clipped ? prev : clipped)); + }; + + document.addEventListener("selectionchange", handleSelectionChange); + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + }; + }, []); const hasMessages = messages.length > 0; @@ -101,7 +120,11 @@ export const ChatPage: React.FC = memo(() => { onRetryMessage={handleRetryMessage} /> ) : ( - + )} diff --git a/src/components/chat/components/EmptyState.tsx b/src/components/chat/components/EmptyState.tsx index c77dd3265..abac2070b 100644 --- a/src/components/chat/components/EmptyState.tsx +++ b/src/components/chat/components/EmptyState.tsx @@ -5,7 +5,7 @@ * @requirements 4.1, 4.4 */ -import React, { memo, useState } from "react"; +import React, { memo, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { MessageSquare, @@ -15,6 +15,12 @@ import { Lightbulb, } from "lucide-react"; import { ProjectSelector } from "@/components/projects/ProjectSelector"; +import { getConfig } from "@/hooks/useTauri"; +import type { ThemeType } from "../types"; +import { + buildRecommendationPrompt, + getContextualRecommendations, +} from "@/components/agent/chat/utils/contextualRecommendations"; const Container = styled.div` flex: 1; @@ -116,32 +122,34 @@ const ProjectSelectorWrapper = styled.div` max-width: 280px; `; -const suggestions = [ - { - icon: Code, - title: "代码问答", - desc: "解释代码、调试问题、优化建议", - prompt: "帮我解释一下这段代码的作用", - }, - { - icon: Lightbulb, - title: "概念解释", - desc: "深入浅出地解释技术概念", - prompt: "用简单的话解释什么是 React Hooks", - }, - { - icon: Languages, - title: "翻译润色", - desc: "翻译文本、润色表达", - prompt: "帮我把这段话翻译成英文", - }, - { - icon: Sparkles, - title: "头脑风暴", - desc: "创意想法、方案建议", - prompt: "帮我想几个产品名字的创意", - }, -]; +const SelectionHint = styled.div` + width: 100%; + max-width: 600px; + margin-bottom: 12px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted) / 0.35); + border: 1px solid hsl(var(--border)); + border-radius: 10px; + padding: 8px 10px; + text-align: left; +`; + +const CHAT_THEME_TO_RECOMMENDATION_THEME: Record = { + general: "general", + knowledge: "knowledge", + planning: "planning", + "social-media": "social-media", + poster: "poster", + document: "document", + paper: "knowledge", + novel: "novel", + script: "video", + music: "music", + video: "video", +}; + +const SUGGESTION_ICONS = [Code, Lightbulb, Languages, Sparkles]; interface EmptyStateProps { /** 点击建议时的回调 */ @@ -150,6 +158,10 @@ interface EmptyStateProps { selectedProjectId?: string | null; /** 项目选择变化回调 */ onProjectChange?: (projectId: string) => void; + /** 当前主题 */ + activeTheme?: ThemeType; + /** 当前选中的文本(用于推荐上下文) */ + selectedText?: string; } /** @@ -158,10 +170,92 @@ interface EmptyStateProps { * 显示欢迎信息、项目选择器和快捷建议 */ export const EmptyState: React.FC = memo( - ({ onSuggestionClick, selectedProjectId, onProjectChange }) => { + ({ + onSuggestionClick, + selectedProjectId, + onProjectChange, + activeTheme = "general", + selectedText = "", + }) => { const [localProjectId, setLocalProjectId] = useState( selectedProjectId || null, ); + const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] = + useState(true); + + useEffect(() => { + const loadConfigPreferences = async () => { + try { + const config = await getConfig(); + setAppendSelectedTextToRecommendation( + config.chat_appearance?.append_selected_text_to_recommendation ?? + true, + ); + } catch (error) { + console.error("加载聊天外观配置失败:", error); + } + }; + + loadConfigPreferences(); + window.addEventListener( + "chat-appearance-config-changed", + loadConfigPreferences, + ); + + return () => { + window.removeEventListener( + "chat-appearance-config-changed", + loadConfigPreferences, + ); + }; + }, []); + + const recommendationTheme = CHAT_THEME_TO_RECOMMENDATION_THEME[activeTheme]; + const recommendationSelectedText = appendSelectedTextToRecommendation + ? selectedText + : ""; + + const suggestions = useMemo(() => { + const recommendationTuples = getContextualRecommendations({ + activeTheme: recommendationTheme, + input: "", + creationMode: "guided", + entryTaskType: "direct", + platform: "xiaohongshu", + hasCanvasContent: false, + hasContentId: false, + selectedText: recommendationSelectedText, + }); + + return recommendationTuples + .slice(0, 4) + .map(([title, prompt], index) => ({ + icon: SUGGESTION_ICONS[index % SUGGESTION_ICONS.length], + title, + desc: prompt, + prompt: buildRecommendationPrompt( + prompt, + selectedText, + appendSelectedTextToRecommendation, + ), + })); + }, [ + recommendationTheme, + recommendationSelectedText, + selectedText, + appendSelectedTextToRecommendation, + ]); + + const selectedTextPreview = useMemo(() => { + const normalized = recommendationSelectedText.trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + + return normalized.length > 60 + ? `${normalized.slice(0, 60).trim()}…` + : normalized; + }, [recommendationSelectedText]); const handleProjectChange = (projectId: string) => { setLocalProjectId(projectId); @@ -187,6 +281,15 @@ export const EmptyState: React.FC = memo( /> + {selectedTextPreview && ( + + 已检测到选中内容,点击推荐会自动附带上下文: + + “{selectedTextPreview}” + + + )} + {suggestions.map((item) => ( void; } | null; + /** 画布选中文本变更 */ + onSelectionTextChange?: (text: string) => void; } /** @@ -50,7 +54,15 @@ interface CanvasFactoryProps { * 优先使用 state.type 来决定渲染哪个画布,以支持 general 等主题 */ export const CanvasFactory: React.FC = memo( - ({ theme, state, onStateChange, onClose, isStreaming, novelControls }) => { + ({ + theme, + state, + onStateChange, + onClose, + isStreaming, + novelControls, + onSelectionTextChange, + }) => { // 优先根据 state.type 渲染,这样 general 主题也能显示文档画布 // 只有当 state.type 与 theme 对应的 canvasType 不匹配时才检查 theme const canvasType = useMemo(() => { @@ -70,6 +82,7 @@ export const CanvasFactory: React.FC = memo( onStateChange={onStateChange as (s: DocumentCanvasState) => void} onClose={onClose} isStreaming={isStreaming} + onSelectionTextChange={onSelectionTextChange} /> ); } @@ -116,6 +129,17 @@ export const CanvasFactory: React.FC = memo( onChapterListCollapsedChange={ novelControls?.onChapterListCollapsedChange } + onSelectionTextChange={onSelectionTextChange} + /> + ); + } + + if (canvasType === "video" && state.type === "video") { + return ( + void} + onClose={onClose} /> ); } diff --git a/src/components/content-creator/canvas/canvasUtils.ts b/src/components/content-creator/canvas/canvasUtils.ts index 4cd52fc4a..ae6d76aab 100644 --- a/src/components/content-creator/canvas/canvasUtils.ts +++ b/src/components/content-creator/canvas/canvasUtils.ts @@ -15,6 +15,8 @@ import { createInitialScriptState } from "./script"; import type { ScriptCanvasState } from "./script/types"; import { createInitialNovelState } from "./novel"; import type { NovelCanvasState } from "./novel/types"; +import { createInitialVideoState } from "./video"; +import type { VideoCanvasState } from "./video/types"; /** * 画布状态联合类型 @@ -24,12 +26,13 @@ export type CanvasStateUnion = | PosterCanvasState | MusicCanvasState | ScriptCanvasState - | NovelCanvasState; + | NovelCanvasState + | VideoCanvasState; /** * 画布类型 */ -export type CanvasType = "document" | "poster" | "music" | "script" | "novel"; +export type CanvasType = "document" | "poster" | "music" | "script" | "novel" | "video"; /** * 主题到画布类型的映射 @@ -48,7 +51,7 @@ const THEME_TO_CANVAS_TYPE: Record = { knowledge: "document", // 知识探索支持文档画布 planning: "document", // 计划规划支持文档画布 document: "document", - video: "script", + video: "video", novel: "novel", }; @@ -86,6 +89,8 @@ export function createInitialCanvasState( return createInitialScriptState(content); case "novel": return createInitialNovelState(content); + case "video": + return createInitialVideoState(content); default: return null; } diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx index 0c261c803..e9b023096 100644 --- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx +++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx @@ -4,7 +4,7 @@ * @module components/content-creator/canvas/document/DocumentCanvas */ -import React, { memo, useMemo, useCallback, useState } from "react"; +import React, { memo, useMemo, useCallback, useState, useEffect } from "react"; import styled from "styled-components"; import type { DocumentCanvasProps, ExportFormat, PlatformType } from "./types"; import { DocumentToolbar } from "./DocumentToolbar"; @@ -58,7 +58,13 @@ const Toast = styled.div<{ $visible: boolean }>` * 文档画布主组件 */ export const DocumentCanvas: React.FC = memo( - ({ state, onStateChange, onClose, isStreaming = false }) => { + ({ + state, + onStateChange, + onClose, + isStreaming = false, + onSelectionTextChange, + }) => { const [editingContent, setEditingContent] = useState(""); const [toastMessage, setToastMessage] = useState(""); const [showToast, setShowToast] = useState(false); @@ -70,6 +76,10 @@ export const DocumentCanvas: React.FC = memo( ); }, [state.versions, state.currentVersionId]); + useEffect(() => { + onSelectionTextChange?.(""); + }, [state.currentVersionId, state.isEditing, onSelectionTextChange]); + // 显示提示 const showMessage = useCallback((message: string) => { setToastMessage(message); @@ -201,12 +211,14 @@ export const DocumentCanvas: React.FC = memo( onChange={setEditingContent} onSave={handleSave} onCancel={handleCancel} + onSelectionTextChange={onSelectionTextChange} /> ) : ( )} diff --git a/src/components/content-creator/canvas/document/DocumentRenderer.tsx b/src/components/content-creator/canvas/document/DocumentRenderer.tsx index 49bae4899..ed968d1cd 100644 --- a/src/components/content-creator/canvas/document/DocumentRenderer.tsx +++ b/src/components/content-creator/canvas/document/DocumentRenderer.tsx @@ -4,7 +4,7 @@ * @module components/content-creator/canvas/document/DocumentRenderer */ -import React, { memo, useState, useEffect, useRef } from "react"; +import React, { memo, useState, useEffect, useRef, useCallback } from "react"; import styled, { keyframes } from "styled-components"; import type { DocumentRendererProps, PlatformType } from "./types"; import { @@ -95,12 +95,49 @@ const getRenderer = (platform: PlatformType, content: string) => { * 支持流式显示 - 按段落逐步显示内容 */ export const DocumentRenderer: React.FC = memo( - ({ content, platform, isStreaming = false }) => { + ({ content, platform, isStreaming = false, onSelectionTextChange }) => { // 用于流式显示的状态 const [displayContent, setDisplayContent] = useState(content); const prevContentRef = useRef(content); const containerRef = useRef(null); + const notifySelection = useCallback(() => { + if (!onSelectionTextChange) { + return; + } + + const container = containerRef.current; + const selection = window.getSelection(); + if (!container || !selection) { + onSelectionTextChange(""); + return; + } + + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + const inContainer = + (!!anchorNode && container.contains(anchorNode)) || + (!!focusNode && container.contains(focusNode)); + + if (!inContainer) { + onSelectionTextChange(""); + return; + } + + const selectedText = selection.toString().trim(); + onSelectionTextChange(selectedText); + }, [onSelectionTextChange]); + + useEffect(() => { + if (!onSelectionTextChange) { + return; + } + + return () => { + onSelectionTextChange(""); + }; + }, [onSelectionTextChange]); + // 流式显示效果:当内容更新时,平滑过渡 useEffect(() => { if (!isStreaming) { @@ -130,7 +167,11 @@ export const DocumentRenderer: React.FC = memo( if (!displayContent || displayContent.trim() === "") { return ( - + 📄 暂无内容 @@ -141,7 +182,11 @@ export const DocumentRenderer: React.FC = memo( } return ( - + {getRenderer(platform, displayContent)} {isStreaming && } diff --git a/src/components/content-creator/canvas/document/editor/NotionEditor.tsx b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx index 0fc723c0d..28bee3e59 100644 --- a/src/components/content-creator/canvas/document/editor/NotionEditor.tsx +++ b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx @@ -22,6 +22,7 @@ interface NotionEditorProps { onChange: (content: string) => void; onSave: () => void; onCancel: () => void; + onSelectionTextChange?: (text: string) => void; } const EMPTY_SLASH: SlashMenuState = { @@ -32,7 +33,7 @@ const EMPTY_SLASH: SlashMenuState = { }; export const NotionEditor: React.FC = memo( - ({ content, onChange, onSave, onCancel }) => { + ({ content, onChange, onSave, onCancel, onSelectionTextChange }) => { const [slashState, setSlashState] = useState(EMPTY_SLASH); const keyDownRef = useRef(null); @@ -83,6 +84,35 @@ export const NotionEditor: React.FC = memo( } }, [editor]); + useEffect(() => { + if (!editor || !onSelectionTextChange) { + return; + } + + const handleSelectionUpdate = () => { + const { from, to, empty } = editor.state.selection; + if (empty) { + onSelectionTextChange(""); + return; + } + + const selectedText = editor.state.doc.textBetween(from, to, "\n").trim(); + onSelectionTextChange(selectedText); + }; + + const handleBlur = () => { + onSelectionTextChange(""); + }; + + editor.on("selectionUpdate", handleSelectionUpdate); + editor.on("blur", handleBlur); + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate); + editor.off("blur", handleBlur); + }; + }, [editor, onSelectionTextChange]); + if (!editor) return null; return ( diff --git a/src/components/content-creator/canvas/document/types.ts b/src/components/content-creator/canvas/document/types.ts index a00ce6438..d0c9e1aac 100644 --- a/src/components/content-creator/canvas/document/types.ts +++ b/src/components/content-creator/canvas/document/types.ts @@ -58,6 +58,8 @@ export interface DocumentCanvasProps { onClose: () => void; /** 是否正在流式输出 */ isStreaming?: boolean; + /** 选中文本变更回调 */ + onSelectionTextChange?: (text: string) => void; } /** @@ -94,6 +96,8 @@ export interface DocumentRendererProps { platform: PlatformType; /** 是否正在流式输出 */ isStreaming?: boolean; + /** 选中文本变更回调 */ + onSelectionTextChange?: (text: string) => void; } /** @@ -118,6 +122,8 @@ export interface DocumentEditorProps { onSave: () => void; /** 取消回调 */ onCancel: () => void; + /** 选中文本变更回调 */ + onSelectionTextChange?: (text: string) => void; } /** diff --git a/src/components/content-creator/canvas/novel/NovelCanvas.tsx b/src/components/content-creator/canvas/novel/NovelCanvas.tsx index efcc84f6b..4977e4249 100644 --- a/src/components/content-creator/canvas/novel/NovelCanvas.tsx +++ b/src/components/content-creator/canvas/novel/NovelCanvas.tsx @@ -176,6 +176,7 @@ interface NovelCanvasProps { useExternalToolbar?: boolean; chapterListCollapsed?: boolean; onChapterListCollapsedChange?: (collapsed: boolean) => void; + onSelectionTextChange?: (text: string) => void; } /** @@ -228,6 +229,7 @@ export const NovelCanvas: React.FC = memo( useExternalToolbar = false, chapterListCollapsed, onChapterListCollapsedChange, + onSelectionTextChange, }) => { const [internalChapterListCollapsed, setInternalChapterListCollapsed] = useState(false); @@ -339,6 +341,10 @@ export const NovelCanvas: React.FC = memo( } }, [currentChapter, state, onStateChange]); + useEffect(() => { + onSelectionTextChange?.(""); + }, [state.currentChapterId, onSelectionTextChange]); + const totalWords = state.chapters.reduce((sum, c) => sum + c.wordCount, 0); const completedCount = state.chapters.filter( (c) => c.status === "completed", @@ -454,6 +460,7 @@ export const NovelCanvas: React.FC = memo( onChange={handleUpdateChapter} onSave={handleToggleStatus} onCancel={() => {}} + onSelectionTextChange={onSelectionTextChange} /> )} diff --git a/src/components/content-creator/canvas/video/PromptInput.tsx b/src/components/content-creator/canvas/video/PromptInput.tsx new file mode 100644 index 000000000..d93e45c8f --- /dev/null +++ b/src/components/content-creator/canvas/video/PromptInput.tsx @@ -0,0 +1,117 @@ +import React, { memo, KeyboardEvent } from "react"; +import styled from "styled-components"; +import { VideoCanvasState } from "./types"; +import { Sparkles } from "lucide-react"; + +interface PromptInputProps { + state: VideoCanvasState; + onStateChange: (state: VideoCanvasState) => void; + onGenerate: () => void; +} + +const PromptWrapper = styled.div` + width: 100%; + max-width: 920px; + margin: 0 auto; +`; + +const InputContainer = styled.div` + display: flex; + align-items: center; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 14px; + padding: 10px 10px 10px 14px; + min-height: 82px; + transition: all 0.2s; + + &:focus-within { + border-color: hsl(var(--border)); + } +`; + +const StyledTextarea = styled.textarea` + flex: 1; + border: none; + background: transparent; + padding: 4px 0; + min-height: 52px; + max-height: 160px; + resize: none; + font-size: 15px; + line-height: 1.6; + color: hsl(var(--foreground)); + outline: none; + + &::placeholder { + color: hsl(var(--muted-foreground)); + } +`; + +const GenerateButton = styled.button<{ $generating?: boolean }>` + flex-shrink: 0; + width: 58px; + height: 58px; + margin-left: 10px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: ${(props) => + props.$generating ? "hsl(var(--muted))" : "hsl(var(--muted) / 0.35)"}; + color: ${(props) => + props.$generating ? "hsl(var(--muted-foreground))" : "hsl(var(--muted-foreground))"}; + border: 1px solid hsl(var(--border)); + cursor: ${(props) => (props.$generating ? "not-allowed" : "pointer")}; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: hsl(var(--muted) / 0.52); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + +export const PromptInput: React.FC = memo( + ({ state, onStateChange, onGenerate }) => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (state.prompt.trim() && state.status !== "generating") { + onGenerate(); + } + } + }; + + return ( + + + { + onStateChange({ ...state, prompt: e.target.value }); + // Auto resize + e.target.style.height = "auto"; + e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`; + }} + onKeyDown={handleKeyDown} + placeholder="描述你想生成的视频内容" + rows={1} + /> + + + + + + ); + }, +); + +PromptInput.displayName = "PromptInput"; diff --git a/src/components/content-creator/canvas/video/VideoCanvas.tsx b/src/components/content-creator/canvas/video/VideoCanvas.tsx new file mode 100644 index 000000000..65deeb64d --- /dev/null +++ b/src/components/content-creator/canvas/video/VideoCanvas.tsx @@ -0,0 +1,377 @@ +import React, { memo, useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; +import { + ChevronLeft, + ChevronRight, + Home, + LayoutGrid, + PanelLeftClose, + PanelLeftOpen, +} from "lucide-react"; +import { VideoCanvasProps } from "./types"; +import { VideoSidebar, type VideoProviderOption } from "./VideoSidebar"; +import { VideoWorkspace } from "./VideoWorkspace"; +import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; + +const VIDEO_MODEL_PRESETS: Record = { + doubao: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"], + volcengine: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"], + dashscope: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"], + alibaba: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"], + qwen: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"], + sora: ["sora-2", "sora-2-pro"], + openai: ["sora-2", "sora-2-pro"], + veo: ["veo-3.1"], + google: ["veo-3.1"], + vertex: ["veo-3.1"], + kling: ["kling-2.6"], + minimax: ["minimax-hailuo-2.3", "minimax-hailuo-02"], + hailuo: ["minimax-hailuo-2.3", "minimax-hailuo-02"], + runway: ["runway-gen-4-turbo"], +}; + +function isVideoProvider(providerId: string): boolean { + const normalized = providerId.toLowerCase(); + return ( + normalized.includes("doubao") || + normalized.includes("volc") || + normalized.includes("dashscope") || + normalized.includes("alibaba") || + normalized.includes("qwen") || + normalized.includes("video") || + normalized.includes("runway") || + normalized.includes("minimax") || + normalized.includes("kling") || + normalized.includes("sora") || + normalized.includes("veo") + ); +} + +function resolveProviderModels(provider: VideoProviderOption): string[] { + if (provider.customModels.length > 0) { + return provider.customModels; + } + + const normalizedId = provider.id.toLowerCase(); + for (const [key, models] of Object.entries(VIDEO_MODEL_PRESETS)) { + if (normalizedId.includes(key)) { + return models; + } + } + + return []; +} + +const Root = styled.div` + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + width: 100%; + padding: 6px 8px 8px; + gap: 6px; + background: hsl(var(--muted) / 0.28); +`; + +const Header = styled.div` + height: 26px; + display: flex; + align-items: center; + gap: 4px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + padding: 0 2px; +`; + +const HeaderHome = styled.button` + border: none; + background: transparent; + color: hsl(var(--muted-foreground)); + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + background: hsl(var(--accent)); + } +`; + +const Body = styled.div` + display: flex; + flex: 1; + min-height: 0; + width: 100%; + gap: 6px; +`; + +const SidebarContainer = styled.div<{ $collapsed: boolean }>` + width: ${({ $collapsed }) => ($collapsed ? "0px" : "304px")}; + flex-shrink: 0; + height: 100%; + min-height: 0; + background: hsl(var(--muted) / 0.34); + border-radius: 12px; + border: none; + overflow-y: auto; + overflow-x: hidden; + opacity: ${({ $collapsed }) => ($collapsed ? 0 : 1)}; + pointer-events: ${({ $collapsed }) => ($collapsed ? "none" : "auto")}; + transition: + width 0.2s ease, + opacity 0.2s ease; +`; + +const Splitter = styled.div` + width: 12px; + display: flex; + justify-content: center; +`; + +const SplitterButton = styled.button` + margin-top: 8px; + width: 16px; + height: 24px; + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--primary) / 0.4); + } +`; + +const MainContainer = styled.div` + flex: 1; + height: 100%; + min-height: 0; + background: hsl(var(--background)); + overflow: hidden; + position: relative; +`; + +const WorkspaceFrame = styled.div` + flex: 1; + min-height: 0; + display: flex; + border-radius: 12px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + overflow: hidden; +`; + +const TopicPanel = styled.div<{ $collapsed: boolean }>` + position: relative; + width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")}; + min-width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")}; + height: 100%; + border-left: ${({ $collapsed }) => + $collapsed ? "none" : "1px solid hsl(var(--border))"}; + background: hsl(var(--background)); + overflow: visible; + transition: + width 0.2s ease, + min-width 0.2s ease; +`; + +const TopicPanelHandle = styled.button` + position: absolute; + left: -14px; + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 50px; + border: 1px solid hsl(var(--border)); + border-right: none; + border-radius: 12px 0 0 12px; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 6; + + &:hover { + color: hsl(var(--foreground)); + } +`; + +const MainAction = styled.button` + position: absolute; + top: 10px; + right: 10px; + z-index: 5; + width: 20px; + height: 20px; + border-radius: 6px; + border: none; + background: transparent; + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; +`; + +export const VideoCanvas: React.FC = memo( + ({ state, onStateChange, projectId, onClose: _onClose, onBackHome }) => { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [topicPanelCollapsed, setTopicPanelCollapsed] = useState(false); + const [providers, setProviders] = useState([]); + + useEffect(() => { + let active = true; + const loadProviders = async () => { + try { + const allProviders = await apiKeyProviderApi.getProviders(); + if (!active) { + return; + } + + const availableProviders = allProviders + .filter( + (provider) => + provider.enabled && + provider.api_key_count > 0 && + isVideoProvider(provider.id), + ) + .map((provider) => ({ + id: provider.id, + name: provider.name, + customModels: provider.custom_models ?? [], + })); + + setProviders(availableProviders); + } catch (error) { + console.error("[VideoCanvas] 加载视频 Provider 失败:", error); + if (active) { + setProviders([]); + } + } + }; + + void loadProviders(); + return () => { + active = false; + }; + }, []); + + const selectedProvider = useMemo(() => { + return ( + providers.find((provider) => provider.id === state.providerId) ?? null + ); + }, [providers, state.providerId]); + + const availableModels = useMemo(() => { + if (!selectedProvider) { + return []; + } + return resolveProviderModels(selectedProvider); + }, [selectedProvider]); + + useEffect(() => { + if (providers.length === 0) { + return; + } + + if ( + !state.providerId || + !providers.some((provider) => provider.id === state.providerId) + ) { + const firstProvider = providers[0]; + const firstModel = resolveProviderModels(firstProvider)[0] ?? ""; + onStateChange({ + ...state, + providerId: firstProvider.id, + model: firstModel, + }); + return; + } + + if (!state.model && availableModels.length > 0) { + onStateChange({ + ...state, + model: availableModels[0], + }); + } + }, [availableModels, onStateChange, providers, state]); + + return ( + +
+ + + + + 视频 +
+ + + + + + + + setSidebarCollapsed((previous) => !previous)} + title={sidebarCollapsed ? "展开侧栏" : "收起侧栏"} + > + {sidebarCollapsed ? ( + + ) : ( + + )} + + + + + + + + + + + + + setTopicPanelCollapsed((previous) => !previous) + } + > + {topicPanelCollapsed ? ( + + ) : ( + + )} + + + + +
+ ); + }, +); + +VideoCanvas.displayName = "VideoCanvas"; diff --git a/src/components/content-creator/canvas/video/VideoSidebar.tsx b/src/components/content-creator/canvas/video/VideoSidebar.tsx new file mode 100644 index 000000000..2f72f9760 --- /dev/null +++ b/src/components/content-creator/canvas/video/VideoSidebar.tsx @@ -0,0 +1,1085 @@ +import React, { memo, useEffect, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; +import { + Check, + ChevronDown, + CircleHelp, + Dices, + ImagePlus, + Monitor, + X, +} from "lucide-react"; +import { VideoCanvasState, VideoAspectRatio, VideoResolution } from "./types"; + +export interface VideoProviderOption { + id: string; + name: string; + customModels: string[]; +} + +interface VideoSidebarProps { + state: VideoCanvasState; + providers: VideoProviderOption[]; + availableModels: string[]; + onStateChange: (state: VideoCanvasState) => void; +} + +const SidebarWrapper = styled.div` + height: 100%; + padding: 10px; + display: flex; + flex-direction: column; + gap: 14px; + background: hsl(var(--muted) / 0.32); +`; + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const SectionTitle = styled.div` + font-size: 14px; + line-height: 1.2; + margin: 0; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const ModelTrigger = styled.button` + width: 100%; + height: 42px; + border-radius: 12px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + padding: 0 12px; + font-size: 14px; + font-weight: 500; + outline: none; + display: inline-flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary) / 0.4); + } + + &:focus-visible { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.12); + } +`; + +const ModelTriggerText = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ModelPanelMask = styled.div` + position: fixed; + inset: 0; + background: hsl(220 36% 6% / 0.65); + z-index: 2100; + display: flex; + align-items: center; + justify-content: center; + padding: 18px; +`; + +const ModelPanel = styled.div` + width: min(760px, calc(100vw - 36px)); + max-height: min(780px, calc(100vh - 36px)); + background: linear-gradient(180deg, #0f172a, #0a1020); + border: 1px solid rgba(110, 130, 170, 0.35); + border-radius: 16px; + padding: 20px 18px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45); +`; + +const ModelPanelTitle = styled.div` + font-size: 28px; + line-height: 1; + transform: scale(0.5); + transform-origin: left center; + margin: -8px 0 -6px 0; + color: #e7edf9; + font-weight: 700; +`; + +const ModelPanelDivider = styled.div` + height: 1px; + background: rgba(134, 157, 197, 0.2); +`; + +const ModelPanelList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + overflow: auto; + padding-right: 2px; +`; + +const ModelPanelItem = styled.button<{ $active: boolean }>` + width: 100%; + border: 1px solid + ${(props) => + props.$active ? "rgba(112, 164, 255, 0.55)" : "rgba(132, 149, 185, 0.26)"}; + border-radius: 14px; + background: ${(props) => + props.$active + ? "linear-gradient(180deg, rgba(45, 80, 145, 0.6), rgba(28, 52, 96, 0.6))" + : "rgba(10, 20, 38, 0.82)"}; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + gap: 10px; + color: #d9e4f7; + text-align: left; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: rgba(117, 173, 255, 0.48); + background: ${(props) => + props.$active + ? "linear-gradient(180deg, rgba(45, 80, 145, 0.65), rgba(28, 52, 96, 0.65))" + : "rgba(18, 32, 60, 0.88)"}; + } +`; + +const ModelPanelBody = styled.div` + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +`; + +const ModelPanelName = styled.div` + font-size: 30px; + line-height: 1; + transform: scale(0.5); + transform-origin: left center; + margin: -7px 0 -6px 0; + font-weight: 700; + color: #f1f5ff; +`; + +const ModelPanelCost = styled.div` + font-size: 24px; + line-height: 1; + transform: scale(0.5); + transform-origin: left center; + margin: -3px 0 -3px 0; + color: #93a6ca; + font-weight: 600; +`; + +const ModelPanelDesc = styled.div` + font-size: 22px; + line-height: 1.15; + transform: scale(0.5); + transform-origin: left center; + margin: -2px 0 -2px 0; + color: #aab8d4; +`; + +const ModelPanelSelected = styled.div<{ $active: boolean }>` + width: 30px; + height: 30px; + border-radius: 999px; + border: 1px solid + ${(props) => (props.$active ? "rgba(92, 154, 255, 0.95)" : "rgba(126, 147, 184, 0.35)")}; + background: ${(props) => + props.$active ? "rgba(56, 117, 221, 0.86)" : "rgba(28, 44, 73, 0.68)"}; + display: inline-flex; + align-items: center; + justify-content: center; + color: #eff5ff; + flex-shrink: 0; +`; + +const ImageUploadArea = styled.div<{ $dragging?: boolean }>` + min-height: 116px; + border-radius: 12px; + background: ${(props) => + props.$dragging ? "hsl(var(--primary) / 0.08)" : "hsl(var(--muted) / 0.3)"}; + border: 1px dashed + ${(props) => (props.$dragging ? "hsl(var(--primary))" : "hsl(var(--border))")}; + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); + transition: + border-color 0.2s, + background 0.2s; + padding: 10px; + cursor: pointer; + + &:hover { + background: hsl(var(--muted) / 0.5); + border-color: hsl(var(--primary)); + } +`; + +const UploadPrompt = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + text-align: center; + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.4; + + svg { + margin-bottom: 6px; + color: hsl(var(--muted-foreground)); + } +`; + +const PreviewBox = styled.div` + width: 100%; + min-height: 116px; + border-radius: 12px; + overflow: hidden; + position: relative; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); + + img { + display: block; + width: 100%; + max-height: 140px; + object-fit: cover; + } +`; + +const ReplaceHint = styled.div` + position: absolute; + left: 0; + right: 0; + bottom: 0; + font-size: 11px; + color: hsl(var(--foreground)); + background: linear-gradient(transparent, hsl(var(--background) / 0.88)); + padding: 20px 8px 8px; + text-align: center; +`; + +const RemovePreviewButton = styled.button` + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border: none; + border-radius: 999px; + background: hsl(var(--background) / 0.9); + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +`; + +const RatioGrid = styled.div` + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +`; + +const RatioItem = styled.div<{ $active?: boolean }>` + min-height: 56px; + border-radius: 10px; + background: ${(props) => + props.$active ? "hsl(var(--primary)/0.1)" : "hsl(var(--muted)/0.3)"}; + border: 1px solid + ${(props) => (props.$active ? "hsl(var(--primary) / 0.5)" : "transparent")}; + color: ${(props) => + props.$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; + + &:hover { + background: ${(props) => + props.$active ? "hsl(var(--primary)/0.1)" : "hsl(var(--muted)/0.5)"}; + } + + svg { + margin-bottom: 3px; + width: 16px; + height: 16px; + } +`; + +const RatioShape = styled.div<{ $active?: boolean }>` + width: 12px; + height: 12px; + border: 1px solid + ${(props) => + props.$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + border-radius: 3px; + margin-bottom: 4px; +`; + +const ResolutionWrapper = styled.div` + padding: 2px; + border-radius: 12px; + background: hsl(var(--muted) / 0.35); +`; + +const ResolutionGroup = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +`; + +const ResolutionButton = styled.button<{ $active?: boolean }>` + height: 34px; + border-radius: 9px; + border: 1px solid + ${(props) => (props.$active ? "hsl(var(--primary))" : "hsl(var(--border))")}; + background: ${(props) => + props.$active ? "hsl(var(--background))" : "hsl(var(--muted) / 0.1)"}; + color: ${(props) => + props.$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); + } +`; + +const DurationRow = styled.div` + display: flex; + align-items: center; + gap: 10px; +`; + +const DurationSlider = styled.input` + flex: 1; + accent-color: hsl(var(--foreground)); + cursor: pointer; +`; + +const DurationValue = styled.input` + width: 52px; + height: 40px; + border-radius: 10px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + text-align: center; + font-size: 14px; + outline: none; +`; + +const SeedRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const SeedInput = styled.input` + flex: 1; + height: 38px; + border-radius: 10px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + padding: 0 10px; + font-size: 12px; + outline: none; +`; + +const SeedRandomButton = styled.button` + width: 38px; + height: 38px; + border-radius: 10px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--primary) / 0.4); + } +`; + +const ToggleRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + line-height: 1.2; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const ToggleSwitch = styled.button<{ $checked: boolean }>` + width: 42px; + height: 24px; + border: none; + border-radius: 999px; + padding: 2px; + cursor: pointer; + background: ${(props) => + props.$checked ? "hsl(var(--foreground))" : "hsl(var(--border))"}; + display: flex; + align-items: center; + justify-content: ${(props) => (props.$checked ? "flex-end" : "flex-start")}; + transition: all 0.2s; +`; + +const ToggleDot = styled.span` + width: 20px; + height: 20px; + border-radius: 999px; + background: hsl(var(--background)); +`; + +const FooterBar = styled.div` + margin-top: auto; + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0 0; +`; + +const FooterButton = styled.button` + width: 22px; + height: 22px; + border: none; + border-radius: 6px; + background: transparent; + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + background: hsl(var(--accent)); + } +`; + +const RATIOS: { label: string; value: VideoAspectRatio }[] = [ + { label: "adaptive", value: "adaptive" }, + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, + { label: "1:1", value: "1:1" }, + { label: "4:3", value: "4:3" }, + { label: "3:4", value: "3:4" }, + { label: "21:9", value: "21:9" }, +]; + +type FrameImageField = "startImage" | "endImage"; +type FrameDropArea = "start" | "end"; + +interface VideoModelOption { + key: string; + providerId: string; + providerName: string; + model: string; + label: string; + cost: string; + description: string; +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("文件读取失败")); + }; + reader.onerror = () => reject(new Error("文件读取失败")); + reader.readAsDataURL(file); + }); +} + +function getModelLabel(model: string): string { + const normalized = model.toLowerCase(); + if ( + normalized === "sora-2-pro" || + normalized.includes("sora-2-pro") || + normalized.includes("sora2-pro") + ) { + return "Sora-2-Pro"; + } + if ( + normalized === "veo-3.1" || + normalized === "veo 3.1" || + normalized.includes("veo-3.1") + ) { + return "Veo 3.1"; + } + if (normalized === "sora-2" || normalized.includes("sora-2")) { + return "Sora-2"; + } + if (normalized.includes("seedance-1-5-pro")) { + return "Seedance 1.5 Pro"; + } + if (normalized.includes("seedance-1-5-lite")) { + return "Seedance 1.5 Lite"; + } + if (normalized === "kling-2.6" || normalized.includes("kling-2.6")) { + return "Kling 2.6"; + } + if ( + normalized === "minimax-hailuo-2.3" || + normalized.includes("hailuo-2.3") + ) { + return "Minimax Hailuo 2.3"; + } + if ( + normalized === "minimax-hailuo-02" || + normalized.includes("hailuo-02") + ) { + return "Minimax Hailuo-02"; + } + if ( + normalized === "runway-gen-4-turbo" || + normalized.includes("runway-gen-4-turbo") + ) { + return "Runway Gen-4 Turbo"; + } + if (normalized.includes("wanx2.1-t2v-turbo")) { + return "Wanx 2.1 T2V Turbo"; + } + if (normalized.includes("wanx2.1-kf2v-plus")) { + return "Wanx 2.1 KF2V Plus"; + } + return model; +} + +function normalizeModelKey(model: string): string { + return model.toLowerCase().replace(/\s+/g, ""); +} + +function getModelMeta(model: string): { cost: string; description: string } { + const normalized = normalizeModelKey(model); + if (normalized.includes("veo-3.1")) { + return { + cost: "30 credits / sec · est. 240 for 8s", + description: "Google Veo 3.1 支持1080p/4K,多图参考与首尾帧", + }; + } + if (normalized.includes("sora-2-pro") || normalized.includes("sora2-pro")) { + return { + cost: "20 credits / sec · est. 80 for 4s", + description: "Sora-2 Pro 生成时间约2分钟,稳定性高", + }; + } + if (normalized.includes("sora-2")) { + return { + cost: "2.7 credits / sec · est. 40.5 for 15s", + description: "Sora2最长15秒,不支持上传人物图", + }; + } + if (normalized.includes("seedance-1-5-pro")) { + return { + cost: "20 credits / sec · est. 100 for 5s", + description: "支持文生视频与首帧/首尾帧图生视频", + }; + } + if (normalized.includes("kling-2.6")) { + return { + cost: "27 credits / sec · est. 135 for 5s", + description: "支持1080p文生视频和图生视频", + }; + } + if (normalized.includes("minimax-hailuo-2.3")) { + return { + cost: "25 credits / sec · est. 150 for 6s", + description: "全新升级的视频生成模型,支持文生视频和图生视频", + }; + } + if (normalized.includes("minimax-hailuo-02")) { + return { + cost: "25 credits / sec · est. 150 for 6s", + description: "支持首尾帧与1080p", + }; + } + if (normalized.includes("runway-gen-4-turbo")) { + return { + cost: "30 credits / sec · est. 150 for 5s", + description: "仅支持图生视频", + }; + } + if (normalized.includes("seedance-1-5-lite")) { + return { + cost: "8 credits / sec · est. 40 for 5s", + description: "轻量版 Seedance,速度更快,成本更低", + }; + } + if (normalized.includes("wanx2.1-t2v-turbo")) { + return { + cost: "18 credits / sec · est. 90 for 5s", + description: "阿里万相文生视频 Turbo 模型", + }; + } + if (normalized.includes("wanx2.1-kf2v-plus")) { + return { + cost: "22 credits / sec · est. 110 for 5s", + description: "阿里万相关键帧图生视频 Plus 模型", + }; + } + return { + cost: "按服务商计费", + description: "具体能力与计费以服务商后台为准", + }; +} + +function nextRandomSeed(): number { + return Math.floor(Math.random() * 1_000_000_000); +} + +export const VideoSidebar: React.FC = memo( + ({ state, providers, availableModels, onStateChange }) => { + const startFileInputRef = useRef(null); + const endFileInputRef = useRef(null); + const modelPanelRef = useRef(null); + const [modelPanelOpen, setModelPanelOpen] = useState(false); + const [draggingArea, setDraggingArea] = useState(null); + + useEffect(() => { + if (!modelPanelOpen) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setModelPanelOpen(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [modelPanelOpen]); + + const modelOptions = useMemo(() => { + const options: VideoModelOption[] = []; + const seenKeys = new Set(); + for (const provider of providers) { + const providerModels = + provider.customModels.length > 0 + ? provider.customModels + : provider.id === state.providerId + ? availableModels + : []; + for (const model of providerModels) { + const key = `${provider.id}::${model}`; + if (seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + const meta = getModelMeta(model); + options.push({ + key, + providerId: provider.id, + providerName: provider.name, + model, + label: getModelLabel(model), + cost: meta.cost, + description: meta.description, + }); + } + } + if (options.length === 0 && state.providerId && state.model) { + const meta = getModelMeta(state.model); + const fallbackProviderName = + providers.find((provider) => provider.id === state.providerId)?.name ?? + state.providerId; + options.push({ + key: `${state.providerId}::${state.model}`, + providerId: state.providerId, + providerName: fallbackProviderName, + model: state.model, + label: getModelLabel(state.model), + cost: meta.cost, + description: meta.description, + }); + } + return options; + }, [availableModels, providers, state.model, state.providerId]); + + const selectedModelKey = useMemo(() => { + const currentKey = `${state.providerId}::${state.model}`; + if (modelOptions.some((item) => item.key === currentKey)) { + return currentKey; + } + return modelOptions[0]?.key ?? ""; + }, [modelOptions, state.model, state.providerId]); + const selectedModelOption = useMemo( + () => modelOptions.find((item) => item.key === selectedModelKey) ?? null, + [modelOptions, selectedModelKey], + ); + + const frameConfigs: { + title: string; + field: FrameImageField; + area: FrameDropArea; + }[] = [ + { title: "起始画面", field: "startImage", area: "start" }, + { title: "结束画面", field: "endImage", area: "end" }, + ]; + + const setFrameImage = (field: FrameImageField, value?: string) => { + if (field === "startImage") { + onStateChange({ ...state, startImage: value }); + return; + } + onStateChange({ ...state, endImage: value }); + }; + + const handleUploadFiles = async ( + field: FrameImageField, + files: FileList | null, + ) => { + const imageFile = Array.from(files ?? []).find((file) => + file.type.startsWith("image/"), + ); + if (!imageFile) { + return; + } + try { + const dataUrl = await fileToDataUrl(imageFile); + setFrameImage(field, dataUrl); + } catch (_error) { + return; + } + }; + + return ( + +
+ setModelPanelOpen(true)} + title="选择视频模型" + > + + {selectedModelOption?.label ?? "暂无可用视频模型"} + + + +
+ + {modelPanelOpen ? ( + { + if (event.target === event.currentTarget) { + setModelPanelOpen(false); + } + }} + > + + AI models + + + {modelOptions.length === 0 ? ( + setModelPanelOpen(false)} + > + + 暂无可用视频模型 + 请先配置支持视频的 Provider + + + + + + ) : ( + modelOptions.map((option) => ( + { + onStateChange({ + ...state, + providerId: option.providerId, + model: option.model, + }); + setModelPanelOpen(false); + }} + > + + {option.label} + {option.cost} + {option.description} + Provider: {option.providerName} + + + {option.key === selectedModelKey ? ( + + ) : null} + + + )) + )} + + + + ) : null} + + {frameConfigs.map((frame) => { + const previewImage = + frame.field === "startImage" ? state.startImage : state.endImage; + const inputRef = + frame.field === "startImage" ? startFileInputRef : endFileInputRef; + + return ( +
+ {frame.title} + inputRef.current?.click()} + onDragOver={(event) => { + event.preventDefault(); + setDraggingArea(frame.area); + }} + onDragLeave={(event) => { + event.preventDefault(); + const relatedTarget = event.relatedTarget as Node | null; + if ( + relatedTarget && + event.currentTarget.contains(relatedTarget) + ) { + return; + } + setDraggingArea((current) => + current === frame.area ? null : current, + ); + }} + onDrop={(event) => { + event.preventDefault(); + setDraggingArea(null); + void handleUploadFiles(frame.field, event.dataTransfer.files); + }} + > + {previewImage ? ( + + {`${frame.title}预览`} + { + event.stopPropagation(); + setFrameImage(frame.field, undefined); + }} + > + + + 点击或拖拽替换图片 + + ) : ( + + +
添加图片
+
点击或拖拽上传
+
+ )} +
+ { + void handleUploadFiles(frame.field, event.target.files); + event.target.value = ""; + }} + /> +
+ ); + })} + +
+ 宽高比 + + {RATIOS.map((ratio) => ( + + onStateChange({ ...state, aspectRatio: ratio.value }) + } + > + + {ratio.label} + + ))} + +
+ +
+ 分辨率 + + + {(["480p", "720p", "1080p"] as VideoResolution[]).map( + (resolution) => ( + onStateChange({ ...state, resolution })} + > + {resolution} + + ), + )} + + +
+ +
+ 时长 + + + onStateChange({ + ...state, + duration: Number.parseInt(event.target.value, 10), + }) + } + /> + { + const value = Number.parseInt(event.target.value, 10); + if (!Number.isFinite(value)) { + return; + } + onStateChange({ + ...state, + duration: Math.min(20, Math.max(1, value)), + }); + }} + /> + +
+ +
+ 种子 + + { + const raw = event.target.value.trim(); + if (!raw) { + onStateChange({ ...state, seed: undefined }); + return; + } + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value)) { + return; + } + onStateChange({ + ...state, + seed: Math.max(0, value), + }); + }} + /> + + onStateChange({ + ...state, + seed: nextRandomSeed(), + }) + } + > + + + +
+ +
+ + 生成音频 + + onStateChange({ ...state, generateAudio: !state.generateAudio }) + } + > + + + + + 固定镜头 + + onStateChange({ ...state, cameraFixed: !state.cameraFixed }) + } + > + + + +
+ + + + + + + + + +
+ ); + }, +); + +VideoSidebar.displayName = "VideoSidebar"; diff --git a/src/components/content-creator/canvas/video/VideoWorkspace.tsx b/src/components/content-creator/canvas/video/VideoWorkspace.tsx new file mode 100644 index 000000000..3c6d138b3 --- /dev/null +++ b/src/components/content-creator/canvas/video/VideoWorkspace.tsx @@ -0,0 +1,645 @@ +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import styled from "styled-components"; +import { Video } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import { VideoCanvasState } from "./types"; +import { PromptInput } from "./PromptInput"; +import { + videoGenerationApi, + type VideoGenerationTask, +} from "@/lib/api/videoGeneration"; + +interface VideoWorkspaceProps { + state: VideoCanvasState; + projectId?: string | null; + onStateChange: (state: VideoCanvasState) => void; +} + +const WorkspaceWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + height: 100%; + width: 100%; + padding: 28px 32px 24px; +`; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-width: 920px; + gap: 32px; +`; + +const EmptyStateWrapper = styled.div` + width: 100%; + max-width: 920px; + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + margin-top: clamp(80px, 16vh, 180px); +`; + +const HeaderIcons = styled.div` + display: flex; + align-items: center; + gap: 14px; +`; + +const IconBox = styled.div` + width: 54px; + height: 54px; + background: hsl(var(--foreground)); + color: hsl(var(--background)); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; +`; + +const Title = styled.h1` + font-size: 48px; + line-height: 1; + font-weight: 700; + color: hsl(var(--foreground)); + margin: 0; +`; + +const VideoPlayerPlaceholder = styled.div` + width: 100%; + aspect-ratio: 16/9; + background: hsl(var(--muted) / 0.3); + border-radius: 12px; + border: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +`; + +const TaskList = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const TaskCard = styled.div` + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--background)); + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +const StatusBadge = styled.span<{ $status: string }>` + display: inline-flex; + align-items: center; + justify-content: center; + height: 22px; + border-radius: 999px; + padding: 0 10px; + font-size: 11px; + background: ${({ $status }) => + $status === "success" + ? "hsl(142 71% 45% / 0.12)" + : $status === "error" + ? "hsl(0 84% 60% / 0.12)" + : "hsl(var(--primary) / 0.12)"}; + color: ${({ $status }) => + $status === "success" + ? "hsl(142 71% 35%)" + : $status === "error" + ? "hsl(0 84% 45%)" + : "hsl(var(--primary))"}; +`; + +const TaskMeta = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const TaskPrompt = styled.div` + font-size: 13px; + color: hsl(var(--foreground)); + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +`; + +interface ImportMaterialFromUrlRequest { + projectId: string; + name: string; + type: "video" | "image"; + url: string; + tags?: string[]; + description?: string; +} + +interface WorkspaceTask extends VideoGenerationTask { + resourceMaterialId?: string; + resourceSavedAt?: number; + resourceSaveError?: string; +} + +const VIDEO_TASK_TAG = "video-gen"; +const VIDEO_REFERENCE_TAG = "video-reference"; + +function isDirectRemoteUrl(url: string): boolean { + return url.startsWith("http://") || url.startsWith("https://"); +} + +function isMaterialReferenceUrl(url: string): boolean { + return url.startsWith("material://"); +} + +function buildVideoMaterialName(task: WorkspaceTask): string { + const promptHead = task.prompt.trim().slice(0, 24) || "生成视频"; + const date = new Date(task.createdAt); + const stamp = [ + date.getFullYear(), + `${date.getMonth() + 1}`.padStart(2, "0"), + `${date.getDate()}`.padStart(2, "0"), + "-", + `${date.getHours()}`.padStart(2, "0"), + `${date.getMinutes()}`.padStart(2, "0"), + `${date.getSeconds()}`.padStart(2, "0"), + ].join(""); + return `${promptHead}-${stamp}.mp4`; +} + +function formatTaskTime(timestamp: number): string { + const date = new Date(timestamp); + return `${date.getHours().toString().padStart(2, "0")}:${date + .getMinutes() + .toString() + .padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; +} + +function mergeTaskList( + previous: WorkspaceTask[], + updates: WorkspaceTask[], +): WorkspaceTask[] { + const updateMap = new Map(updates.map((task) => [task.id, task])); + const merged = previous.map((task) => { + const updated = updateMap.get(task.id); + if (!updated) { + return task; + } + return { + ...task, + ...updated, + resourceMaterialId: task.resourceMaterialId ?? updated.resourceMaterialId, + resourceSavedAt: task.resourceSavedAt ?? updated.resourceSavedAt, + resourceSaveError: updated.resourceSaveError ?? task.resourceSaveError, + }; + }); + + for (const task of updates) { + if (!merged.some((item) => item.id === task.id)) { + merged.push(task); + } + } + + merged.sort((left, right) => right.createdAt - left.createdAt); + return merged; +} + +export const VideoWorkspace: React.FC = memo( + ({ state, projectId, onStateChange }) => { + const [tasks, setTasks] = useState([]); + const pollingGuard = useRef(false); + const savingTaskIdsRef = useRef>(new Set()); + const materialRefCache = useRef>(new Map()); + + useEffect(() => { + materialRefCache.current.clear(); + }, [projectId]); + + const syncPrimaryState = useCallback( + (taskList: WorkspaceTask[]) => { + if (taskList.length === 0) { + return; + } + const latestTask = taskList[0]; + if (latestTask.status === "success" && latestTask.resultUrl) { + if ( + state.status !== "success" || + state.videoUrl !== latestTask.resultUrl + ) { + onStateChange({ + ...state, + status: "success", + videoUrl: latestTask.resultUrl, + errorMessage: undefined, + }); + } + return; + } + if (latestTask.status === "error") { + const message = latestTask.errorMessage ?? "视频生成失败"; + if (state.status !== "error" || state.errorMessage !== message) { + onStateChange({ + ...state, + status: "error", + errorMessage: message, + }); + } + return; + } + if ( + latestTask.status === "pending" || + latestTask.status === "processing" + ) { + if (state.status !== "generating") { + onStateChange({ + ...state, + status: "generating", + errorMessage: undefined, + }); + } + } + }, + [onStateChange, state], + ); + + const saveVideoToResource = useCallback( + async (task: WorkspaceTask): Promise => { + if (!projectId || !task.resultUrl || task.resourceMaterialId) { + return; + } + if (savingTaskIdsRef.current.has(task.id)) { + return; + } + + savingTaskIdsRef.current.add(task.id); + try { + const request: ImportMaterialFromUrlRequest = { + projectId, + name: buildVideoMaterialName(task), + type: "video", + url: task.resultUrl, + tags: [VIDEO_TASK_TAG], + description: `视频生成自动入库(服务:${task.providerId},模型:${task.model})`, + }; + const savedMaterial = await invoke<{ id: string }>( + "import_material_from_url", + { + req: request, + }, + ); + + setTasks((previous) => + previous.map((item) => + item.id === task.id + ? { + ...item, + resourceMaterialId: savedMaterial.id, + resourceSavedAt: Date.now(), + resourceSaveError: undefined, + } + : item, + ), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + setTasks((previous) => + previous.map((item) => + item.id === task.id + ? { ...item, resourceSaveError: errorMessage } + : item, + ), + ); + } finally { + savingTaskIdsRef.current.delete(task.id); + } + }, + [projectId], + ); + + useEffect(() => { + if (!projectId) { + setTasks([]); + return; + } + + let active = true; + const loadTasks = async () => { + try { + const list = await videoGenerationApi.listTasks(projectId, { + limit: 50, + }); + if (!active) { + return; + } + const mapped = list.map((task) => ({ ...task })); + setTasks(mapped); + syncPrimaryState(mapped); + } catch (error) { + console.error("[VideoWorkspace] 加载视频任务失败:", error); + } + }; + + void loadTasks(); + return () => { + active = false; + }; + }, [projectId, syncPrimaryState]); + + const runningTaskIds = useMemo( + () => + tasks + .filter( + (task) => task.status === "pending" || task.status === "processing", + ) + .map((task) => task.id), + [tasks], + ); + + useEffect(() => { + if (runningTaskIds.length === 0) { + return; + } + let active = true; + + const tick = async () => { + if (!active || pollingGuard.current) { + return; + } + pollingGuard.current = true; + try { + const updates = await Promise.all( + runningTaskIds.map((taskId) => + videoGenerationApi.getTask(taskId, { refreshStatus: true }), + ), + ); + + if (!active) { + return; + } + + const normalizedUpdates = updates.filter( + (task): task is WorkspaceTask => task !== null, + ); + if (normalizedUpdates.length === 0) { + return; + } + + setTasks((previous) => { + const merged = mergeTaskList(previous, normalizedUpdates); + syncPrimaryState(merged); + return merged; + }); + + for (const task of normalizedUpdates) { + if (task.status === "success" && task.resultUrl) { + void saveVideoToResource(task); + } + } + } finally { + pollingGuard.current = false; + } + }; + + void tick(); + const timer = window.setInterval(() => { + void tick(); + }, 3000); + + return () => { + active = false; + window.clearInterval(timer); + }; + }, [runningTaskIds, saveVideoToResource, syncPrimaryState]); + + const ensureReferenceImageUrl = useCallback( + async ( + imageUrl: string | undefined, + frameType: "start" | "end", + ): Promise => { + const normalizedUrl = imageUrl?.trim(); + if (!normalizedUrl) { + return undefined; + } + if ( + isDirectRemoteUrl(normalizedUrl) || + isMaterialReferenceUrl(normalizedUrl) + ) { + return normalizedUrl; + } + if (!normalizedUrl.startsWith("data:")) { + throw new Error("参考图格式不支持,请重新上传图片"); + } + + const cached = materialRefCache.current.get(normalizedUrl); + if (cached) { + return cached; + } + + if (!projectId) { + throw new Error("未选择项目,无法处理参考图"); + } + + const request: ImportMaterialFromUrlRequest = { + projectId, + name: frameType === "start" ? "视频首帧参考图" : "视频尾帧参考图", + type: "image", + url: normalizedUrl, + tags: [VIDEO_REFERENCE_TAG, frameType], + description: + frameType === "start" + ? "视频生成首帧参考图(自动上传)" + : "视频生成尾帧参考图(自动上传)", + }; + const material = await invoke<{ id: string }>("import_material_from_url", { + req: request, + }); + + const materialUrl = `material://${material.id}`; + materialRefCache.current.set(normalizedUrl, materialUrl); + return materialUrl; + }, + [projectId], + ); + + const handleGenerate = useCallback(async () => { + if (!projectId) { + toast.error("请先选择项目后再生成视频"); + return; + } + if (!state.providerId) { + toast.error("请选择视频服务"); + return; + } + if (!state.model) { + toast.error("请选择视频模型"); + return; + } + if (!state.prompt.trim()) { + toast.error("请输入视频描述"); + return; + } + const providerNormalized = state.providerId.trim().toLowerCase(); + const supportedProvider = + providerNormalized.includes("doubao") || + providerNormalized.includes("volc") || + providerNormalized.includes("dashscope") || + providerNormalized.includes("alibaba") || + providerNormalized.includes("qwen"); + if (!supportedProvider) { + toast.error("当前仅支持火山或阿里兼容视频服务"); + return; + } + + onStateChange({ + ...state, + status: "generating", + errorMessage: undefined, + }); + try { + const [resolvedStartImageUrl, resolvedEndImageUrl] = await Promise.all([ + ensureReferenceImageUrl(state.startImage, "start"), + ensureReferenceImageUrl(state.endImage, "end"), + ]); + + const created = await videoGenerationApi.createTask({ + projectId, + providerId: state.providerId, + model: state.model, + prompt: state.prompt.trim(), + aspectRatio: state.aspectRatio, + resolution: state.resolution, + duration: state.duration, + imageUrl: resolvedStartImageUrl, + endImageUrl: resolvedEndImageUrl, + seed: state.seed, + generateAudio: state.generateAudio, + cameraFixed: state.cameraFixed, + }); + + setTasks((previous) => { + const merged = mergeTaskList(previous, [created]); + return merged; + }); + toast.success("视频任务已提交,正在生成"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onStateChange({ + ...state, + status: "error", + errorMessage: message, + }); + toast.error(message); + } + }, [ensureReferenceImageUrl, onStateChange, projectId, state]); + + const isGenerated = tasks.length > 0 || state.status !== "idle"; + + return ( + + {!isGenerated ? ( + + + + + 视频 + + + + ) : ( + + + {state.status === "generating" ? ( + 正在生成视频中... + ) : state.status === "error" ? ( + {state.errorMessage ?? "视频生成失败"} + ) : state.videoUrl ? ( + + + + {tasks.map((task) => ( + + + + {task.status === "success" + ? "已完成" + : task.status === "error" + ? "失败" + : task.status === "cancelled" + ? "已取消" + : "生成中"} + + {formatTaskTime(task.createdAt)} + + {task.prompt} + + + {task.providerId} · {task.model} + + + {task.progress !== undefined && task.progress !== null + ? `${task.progress}%` + : "--"} + + + {task.errorMessage ? ( +
+ {task.errorMessage} +
+ ) : null} +
+ ))} +
+ + +
+ )} +
+ ); + }, +); + +VideoWorkspace.displayName = "VideoWorkspace"; diff --git a/src/components/content-creator/canvas/video/index.ts b/src/components/content-creator/canvas/video/index.ts new file mode 100644 index 000000000..437b2a3c1 --- /dev/null +++ b/src/components/content-creator/canvas/video/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './VideoCanvas'; diff --git a/src/components/content-creator/canvas/video/types.ts b/src/components/content-creator/canvas/video/types.ts new file mode 100644 index 000000000..d7e27cdb9 --- /dev/null +++ b/src/components/content-creator/canvas/video/types.ts @@ -0,0 +1,52 @@ +export type VideoAspectRatio = + | "adaptive" + | "16:9" + | "9:16" + | "1:1" + | "4:3" + | "3:4" + | "21:9"; +export type VideoResolution = "480p" | "720p" | "1080p"; +export type VideoStatus = "idle" | "generating" | "success" | "error"; + +export interface VideoCanvasState { + type: "video"; + prompt: string; + providerId: string; + model: string; + duration: number; + seed?: number; + generateAudio: boolean; + cameraFixed: boolean; + startImage?: string; + endImage?: string; + aspectRatio: VideoAspectRatio; + resolution: VideoResolution; + status: VideoStatus; + videoUrl?: string; + errorMessage?: string; +} + +export interface VideoCanvasProps { + state: VideoCanvasState; + onStateChange: (state: VideoCanvasState) => void; + projectId?: string | null; + onClose?: () => void; + onBackHome?: () => void; +} + +export const createInitialVideoState = ( + content?: string, +): VideoCanvasState => ({ + type: "video", + prompt: content || "", + providerId: "", + model: "", + duration: 5, + seed: undefined, + generateAudio: false, + cameraFixed: false, + aspectRatio: "adaptive", + resolution: "720p", + status: "idle", +}); diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index d8443ad81..d646c8d52 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -13,8 +13,9 @@ import { SettingsTabs } from "@/types/settings"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { Page, PageParams } from "@/types/page"; -// 外观设置(迁移自原 GeneralSettings) -import { GeneralSettings } from "../../settings/GeneralSettings"; +// 外观设置 +import { AppearanceSettings } from '../general/appearance'; +import { ChatAppearanceSettings } from '../general/chat-appearance'; // 网络代理 import { ProxySettings } from "../../settings/ProxySettings"; // 安全与性能 @@ -34,8 +35,6 @@ import { AboutSection } from "../../settings/AboutSection"; import { ExtensionsSettings } from "../../settings/ExtensionsSettings"; // 快捷键设置 import { HotkeysSettings } from "../general/hotkeys"; -// 聊天外观设置 -import { ChatAppearanceSettings } from "../general/chat-appearance"; // 记忆设置 // 语音服务设置 import { VoiceSettings } from "../agent/voice"; @@ -160,7 +159,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - + ); diff --git a/src/components/settings-v2/general/appearance/index.tsx b/src/components/settings-v2/general/appearance/index.tsx new file mode 100644 index 000000000..96ecd66f2 --- /dev/null +++ b/src/components/settings-v2/general/appearance/index.tsx @@ -0,0 +1,242 @@ +/** + * @file index.tsx + * @description 通用设置 - 外观与语言 + */ + +import { useState, useEffect, useCallback } from "react"; +import styled from "styled-components"; +import { Moon, Sun, Monitor, Volume2, RotateCcw } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; +import { useOnboardingState } from "@/components/onboarding"; +import { LanguageSelector, Language } from "../../../settings/LanguageSelector"; +import { useI18nPatch } from "@/i18n/I18nPatchProvider"; +import { useSoundContext } from "@/contexts/useSoundContext"; + +type Theme = "light" | "dark" | "system"; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; +`; + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SectionTitle = styled.h3` + font-size: 14px; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; + padding-bottom: 8px; + border-bottom: 1px solid hsl(var(--border)); +`; + +const SettingItem = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; +`; + +const SettingInfo = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const SettingLabel = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: hsl(var(--foreground)); +`; + +const SettingDescription = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const ThemeButtonGroup = styled.div` + display: flex; + gap: 4px; + background: hsl(var(--muted)); + padding: 4px; + border-radius: 8px; +`; + +const ThemeButton = styled.button<{ $active: boolean }>` + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + background: ${({ $active }) => ($active ? "hsl(var(--background))" : "transparent")}; + color: ${({ $active }) => ($active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))")}; + box-shadow: ${({ $active }) => ($active ? "0 1px 3px rgba(0,0,0,0.1)" : "none")}; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: hsl(var(--foreground)); + } + + svg { + width: 14px; + height: 14px; + } +`; + +export function AppearanceSettings() { + const [theme, setTheme] = useState("system"); + const [language, setLanguageState] = useState("zh"); + const [config, setConfig] = useState(null); + + const { setLanguage: setI18nLanguage } = useI18nPatch(); + const { soundEnabled, setSoundEnabled, playToolcallSound } = useSoundContext(); + const { resetOnboarding } = useOnboardingState(); + + useEffect(() => { + const savedTheme = localStorage.getItem("theme") as Theme | null; + if (savedTheme) { + setTheme(savedTheme); + } + loadConfig(); + }, []); + + const loadConfig = async () => { + try { + const c = await getConfig(); + setConfig(c); + setLanguageState((c.language || "zh") as Language); + } catch (e) { + console.error("加载配置失败:", e); + } + }; + + const handleThemeChange = (newTheme: Theme) => { + setTheme(newTheme); + localStorage.setItem("theme", newTheme); + const root = document.documentElement; + if (newTheme === "system") { + const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + root.classList.toggle("dark", systemDark); + } else { + root.classList.toggle("dark", newTheme === "dark"); + } + }; + + const handleLanguageChange = async (newLanguage: Language) => { + if (!config) return; + try { + const newConfig = { ...config, language: newLanguage }; + await saveConfig(newConfig); + setConfig(newConfig); + setLanguageState(newLanguage); + setI18nLanguage(newLanguage); + } catch (err) { + console.error("保存语言设置失败:", err); + } + }; + + const handleResetOnboarding = useCallback(() => { + resetOnboarding(); + window.location.reload(); + }, [resetOnboarding]); + + const themeOptions = [ + { id: "light" as Theme, label: "浅色", icon: Sun }, + { id: "dark" as Theme, label: "深色", icon: Moon }, + { id: "system" as Theme, label: "系统", icon: Monitor }, + ]; + + return ( + +
+ 基础外观 + + + + 主题模式 + 选择应用的主题颜色体系 + + + {themeOptions.map((option) => ( + handleThemeChange(option.id)} + > + + {option.label} + + ))} + + + + + + 语言 + 选择应用的显示语言 + + + + + + + + + 提示音效 + + 在工具调用和消息生成时播放提示音 + + { + setSoundEnabled(e.target.checked); + if (e.target.checked) { + playToolcallSound(); + } + }} + className="w-4 h-4 rounded border-gray-300" + /> + +
+ +
+ 初始化 + + + 重置向导设置 + 遇到问题或想重新选择启动选项时,可重新运行初始化向导 + + + +
+
+ ); +} + +export default AppearanceSettings; diff --git a/src/components/settings-v2/general/chat-appearance/index.tsx b/src/components/settings-v2/general/chat-appearance/index.tsx index 34d8d4e7a..d077e9fcf 100644 --- a/src/components/settings-v2/general/chat-appearance/index.tsx +++ b/src/components/settings-v2/general/chat-appearance/index.tsx @@ -1,397 +1,344 @@ /** - * 聊天外观设置组件 - * - * 参考成熟产品的聊天外观实现 - * 功能包括:聊天气泡样式、字体大小、过渡模式等 + * @file index.tsx + * @description 通用设置 - 聊天外观与模块定制 */ import { useState, useEffect } from "react"; -import { Type, Sparkles, MessageSquare, Monitor, Info } from "lucide-react"; +import styled from "styled-components"; +import { Palette } from "lucide-react"; import { cn } from "@/lib/utils"; import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; +import { Switch } from "@/components/ui/switch"; -type TransitionMode = "none" | "fadeIn" | "smooth"; -type BubbleStyle = "default" | "minimal" | "colorful"; +const ALL_CONTENT_THEMES = [ + { id: "general", label: "通用" }, + { id: "social-media", label: "社媒内容" }, + { id: "poster", label: "图文海报" }, + { id: "music", label: "歌词曲谱" }, + { id: "video", label: "短视频" }, + { id: "novel", label: "小说" }, + { id: "knowledge", label: "知识探索" }, + { id: "planning", label: "计划规划" }, + { id: "document", label: "办公文档" }, +] as const; -interface ChatAppearanceConfig { - fontSize?: number; // 12-18 - transitionMode?: TransitionMode; - bubbleStyle?: BubbleStyle; - showAvatar?: boolean; - showTimestamp?: boolean; -} +const DEFAULT_ENABLED_THEMES = [ + "general", + "social-media", + "poster", + "music", + "video", + "novel", +]; -const DEFAULT_CHAT_APPEARANCE: ChatAppearanceConfig = { - fontSize: 14, - transitionMode: "smooth", - bubbleStyle: "default", - showAvatar: true, - showTimestamp: true, -}; +const ALL_NAV_ITEMS = [ + { id: "home-general", label: "首页" }, + { id: "video", label: "视频" }, + { id: "image-gen", label: "绘画" }, + { id: "batch", label: "批量任务" }, + { id: "plugins", label: "插件中心" }, +] as const; -/** - * 字体大小预览组件 - */ -function FontSizePreview({ fontSize }: { fontSize: number }) { - const sampleText = `这是示例文本 +const DEFAULT_ENABLED_NAV_ITEMS = [ + "home-general", + "video", + "image-gen", + "plugins", +]; -## 标题示例 -这是一段普通文本,展示当前的字体大小效果。 - -- 列表项 1 -- 列表项 2 - -**粗体文本** 和 *斜体文本* +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; `; - return ( -
-
{sampleText}
-
- ); -} +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; -/** - * 过渡模式预览组件 - */ -function TransitionPreview({ mode }: { mode: TransitionMode }) { - const [messages, setMessages] = useState([]); +const SectionTitle = styled.h3` + font-size: 14px; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; + padding-bottom: 8px; + border-bottom: 1px solid hsl(var(--border)); +`; - useEffect(() => { - setMessages([]); - const timer = setTimeout(() => { - setMessages(["你好!"]); - }, 300); - return () => clearTimeout(timer); - }, [mode]); +const SettingItem = styled.div` + display: flex; + flex-direction: column; + padding: 16px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + gap: 16px; +`; - return ( -
- {messages.map((msg, i) => ( -
- {msg} -
- ))} -
- ); -} +const SettingHeader = styled.div` + display: flex; + align-items: flex-start; + gap: 12px; +`; -/** - * 气泡样式预览组件 - */ -function BubbleStylePreview({ style }: { style: BubbleStyle }) { - const bubbles = [ - { text: "你好,有什么可以帮助你的吗?", align: "left" }, - { text: "帮我写一段代码", align: "right" }, - ]; +const SettingIcon = styled.div` + color: hsl(var(--muted-foreground)); + padding-top: 2px; +`; - const getBubbleClass = (align: string) => { - const baseClass = "max-w-[70%] px-3 py-2 rounded-lg"; - if (style === "minimal") { - return cn( - baseClass, - align === "left" - ? "bg-muted text-foreground" - : "bg-primary/20 text-foreground", - ); - } else if (style === "colorful") { - return cn( - baseClass, - align === "left" - ? "bg-gradient-to-br from-blue-500 to-blue-600 text-white" - : "bg-gradient-to-br from-purple-500 to-purple-600 text-white", - ); +const SettingInfo = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const SettingLabel = styled.div` + font-size: 14px; + font-weight: 500; + color: hsl(var(--foreground)); +`; + +const SettingDescription = styled.div` + font-size: 13px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +`; + +const TagsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-left: 36px; +`; + +const ToggleRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-left: 36px; + gap: 12px; +`; + +const ToggleInfo = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +`; + +const TagButton = styled.button<{ $active: boolean }>` + px: 12px; + py: 6px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + transition: all 0.2s; + ${({ $active }) => + $active + ? ` + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border: none; + ` + : ` + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + border: 1px solid transparent; + &:hover { + background: hsl(var(--muted)/0.8); } - // default - return cn( - baseClass, - align === "left" - ? "bg-muted text-foreground" - : "bg-primary text-primary-foreground", - ); - }; - - return ( -
- {bubbles.map((bubble, i) => ( -
-
{bubble.text}
-
- ))} -
- ); -} + `} +`; export function ChatAppearanceSettings() { - const [config, setConfig] = useState(null); - const [chatConfig, setChatConfig] = useState( - DEFAULT_CHAT_APPEARANCE, + const [enabledThemes, setEnabledThemes] = useState( + DEFAULT_ENABLED_THEMES, ); - const [_loading, setLoading] = useState(true); - const [_saving, setSaving] = useState>({}); + const [enabledNavItems, setEnabledNavItems] = useState( + DEFAULT_ENABLED_NAV_ITEMS, + ); + const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] = + useState(true); + const [config, setConfig] = useState(null); - // 加载配置 useEffect(() => { loadConfig(); }, []); const loadConfig = async () => { - setLoading(true); try { const c = await getConfig(); setConfig(c); - setChatConfig(c.chat_appearance || DEFAULT_CHAT_APPEARANCE); + setEnabledThemes( + c.content_creator?.enabled_themes || DEFAULT_ENABLED_THEMES, + ); + setEnabledNavItems( + c.navigation?.enabled_items || DEFAULT_ENABLED_NAV_ITEMS, + ); + setAppendSelectedTextToRecommendation( + c.chat_appearance?.append_selected_text_to_recommendation ?? true, + ); } catch (e) { - console.error("加载聊天外观配置失败:", e); - } finally { - setLoading(false); + console.error("加载配置失败:", e); } }; - // 保存配置 - const saveChatConfig = async ( - key: keyof ChatAppearanceConfig, - value: any, - ) => { + const handleThemeToggle = async (themeId: string) => { if (!config) return; - setSaving((prev) => ({ ...prev, [key]: true })); + const newThemes = enabledThemes.includes(themeId) + ? enabledThemes.filter((t) => t !== themeId) + : [...enabledThemes, themeId]; + + if (newThemes.length === 0) return; + + setEnabledThemes(newThemes); + try { + const newConfig = { + ...config, + content_creator: { enabled_themes: newThemes }, + }; + await saveConfig(newConfig); + setConfig(newConfig); + window.dispatchEvent(new CustomEvent("theme-config-changed")); + } catch (err) { + console.error("保存主题设置失败:", err); + setEnabledThemes(enabledThemes); + } + }; + + const handleNavItemToggle = async (itemId: string) => { + if (!config) return; + const newItems = enabledNavItems.includes(itemId) + ? enabledNavItems.filter((i) => i !== itemId) + : [...enabledNavItems, itemId]; + + if (newItems.length === 0) return; + + setEnabledNavItems(newItems); + try { + const newConfig = { + ...config, + navigation: { enabled_items: newItems }, + }; + await saveConfig(newConfig); + setConfig(newConfig); + window.dispatchEvent(new CustomEvent("nav-config-changed")); + } catch (err) { + console.error("保存导航设置失败:", err); + setEnabledNavItems(enabledNavItems); + } + }; + + const handleRecommendationSelectionToggle = async (checked: boolean) => { + if (!config) return; + const previousValue = appendSelectedTextToRecommendation; + setAppendSelectedTextToRecommendation(checked); try { const newConfig = { - ...chatConfig, - [key]: value, - }; - const updatedFullConfig = { ...config, - chat_appearance: newConfig, + chat_appearance: { + ...(config.chat_appearance || {}), + append_selected_text_to_recommendation: checked, + }, }; - await saveConfig(updatedFullConfig); - setConfig(updatedFullConfig); - setChatConfig(newConfig); - } catch (e) { - console.error("保存聊天外观配置失败:", e); - } finally { - setSaving((prev) => ({ ...prev, [key]: false })); + await saveConfig(newConfig); + setConfig(newConfig); + window.dispatchEvent(new CustomEvent("chat-appearance-config-changed")); + } catch (err) { + console.error("保存推荐上下文设置失败:", err); + setAppendSelectedTextToRecommendation(previousValue); } }; - const transitionModeOptions: { - value: TransitionMode; - label: string; - desc: string; - }[] = [ - { - value: "none", - label: "无动画", - desc: "消息立即显示", - }, - { - value: "fadeIn", - label: "淡入", - desc: "消息淡入显示", - }, - { - value: "smooth", - label: "平滑", - desc: "平滑过渡效果", - }, - ]; - - const bubbleStyleOptions: { - value: BubbleStyle; - label: string; - desc: string; - }[] = [ - { - value: "default", - label: "默认", - desc: "经典聊天气泡样式", - }, - { - value: "minimal", - label: "简约", - desc: "简约气泡风格", - }, - { - value: "colorful", - label: "彩色", - desc: "渐变彩色气泡", - }, - ]; - return ( -
- {/* 字体大小 */} -
-
-
- -
-

字体大小

-

- 调整聊天消息的字体大小 -

-
-
- - {chatConfig.fontSize}px - -
+ +
+ 工作区定制 -
- { - const value = parseInt(e.target.value); - setChatConfig((prev) => ({ ...prev, fontSize: value })); - }} - onChangeCapture={(e) => { - saveChatConfig( - "fontSize", - parseInt((e.target as HTMLInputElement).value), - ); - }} - className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" - /> -
- 小 (12px) - 中 (14px) - 大 (18px) -
-
+ + + + + + + 创作模式卡片 + 选择您希望在创建新项目时可以使用的快捷内容创作模板,它们会在新对话页面展现。 + + - -
+ + {ALL_CONTENT_THEMES.map((t) => ( + handleThemeToggle(t.id)} + className={cn( + "px-3 py-1.5 rounded-full text-xs font-medium transition-colors", + enabledThemes.includes(t.id) + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground hover:bg-muted/80", + )} + > + {t.label} + + ))} + + - {/* 过渡模式 */} -
-
- -
-

消息过渡效果

-

- 选择消息显示的动画效果 -

-
-
+ + + + + + + 左侧边栏导航 + 定制主视图左侧边栏启用的常驻导航图标入口,最少须保留一个。 + + -
- {transitionModeOptions.map((option) => ( - - ))} -
+ + {ALL_NAV_ITEMS.map((item) => ( + handleNavItemToggle(item.id)} + className={cn( + "px-3 py-1.5 rounded-full text-xs font-medium transition-colors", + enabledNavItems.includes(item.id) + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground hover:bg-muted/80", + )} + > + {item.label} + + ))} + +
- -
+ + + + + + + 推荐自动附带选中内容 + 开启后,点击推荐提示词会自动追加当前编辑器选中文本作为上下文。 + + - {/* 气泡样式 */} -
-
- -
-

聊天气泡样式

-

- 自定义聊天气泡的视觉风格 -

-
-
- -
- {bubbleStyleOptions.map((option) => ( - - ))} -
- - -
- - {/* 显示选项 */} -
-
- -
-

显示选项

-

- 控制聊天界面的元素显示 -

-
-
- -
- + + - -
-
- - {/* 提示信息 */} -
- -

- 这些设置会应用到所有聊天对话。部分效果可能需要刷新对话窗口后才能看到。 -

-
-
+
+ ); } diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx index b344a3693..96361373b 100644 --- a/src/components/settings/GeneralSettings.tsx +++ b/src/components/settings/GeneralSettings.tsx @@ -47,16 +47,20 @@ const DEFAULT_ENABLED_THEMES = [ /** 所有可用的导航模块 */ const ALL_NAV_ITEMS = [ - { id: "agent", label: "AI Agent" }, - { id: "projects", label: "项目" }, - { id: "image-gen", label: "图片生成" }, - { id: "terminal", label: "终端" }, - { id: "tools", label: "工具" }, + { id: "home-general", label: "首页" }, + { id: "video", label: "视频" }, + { id: "image-gen", label: "绘画" }, + { id: "batch", label: "批量任务" }, { id: "plugins", label: "插件中心" }, ] as const; /** 默认启用的导航模块 */ -const DEFAULT_ENABLED_NAV_ITEMS = ["agent", "projects", "image-gen"]; +const DEFAULT_ENABLED_NAV_ITEMS = [ + "home-general", + "video", + "image-gen", + "plugins", +]; export function GeneralSettings() { const [theme, setTheme] = useState("system"); diff --git a/src/components/workspace/WorkbenchPage.tsx b/src/components/workspace/WorkbenchPage.tsx index 10eb13915..71cce76df 100644 --- a/src/components/workspace/WorkbenchPage.tsx +++ b/src/components/workspace/WorkbenchPage.tsx @@ -77,6 +77,11 @@ import type { WorkflowProgressSnapshot } from "@/components/agent/chat"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage"; import type { CreationMode } from "@/components/content-creator/types"; +import { + VideoCanvas, + createInitialVideoState, + type VideoCanvasState as StandaloneVideoCanvasState, +} from "@/components/content-creator/canvas/video"; import { buildCreationIntentMetadata, buildCreationIntentPrompt, @@ -206,16 +211,22 @@ export function WorkbenchPage({ const [selectedCreationMode, setSelectedCreationMode] = useState(DEFAULT_CREATION_MODE); const [creationIntentValues, setCreationIntentValues] = - useState(() => createInitialCreationIntentValues()); + useState(() => + createInitialCreationIntentValues(), + ); const [creationIntentError, setCreationIntentError] = useState(""); - const [pendingInitialPromptsByContentId, setPendingInitialPromptsByContentId] = - useState>({}); + const [ + pendingInitialPromptsByContentId, + setPendingInitialPromptsByContentId, + ] = useState>({}); const [contentCreationModes, setContentCreationModes] = useState< Record >({}); const [resolvedProjectPath, setResolvedProjectPath] = useState(""); const [pathChecking, setPathChecking] = useState(false); const [pathConflictMessage, setPathConflictMessage] = useState(""); + const [videoCanvasState, setVideoCanvasState] = + useState(() => createInitialVideoState()); const selectedProject = useMemo( () => projects.find((project) => project.id === selectedProjectId) ?? null, @@ -260,8 +271,9 @@ export function WorkbenchPage({ ); const currentIntentLength = useMemo( - () => validateCreationIntent(creationIntentInput, MIN_CREATION_INTENT_LENGTH) - .length, + () => + validateCreationIntent(creationIntentInput, MIN_CREATION_INTENT_LENGTH) + .length, [creationIntentInput], ); @@ -434,71 +446,65 @@ export function WorkbenchPage({ setCreationIntentError(""); }, []); - const handleCreateContent = useCallback( - async () => { - if (!selectedProjectId) { - return; - } + const handleCreateContent = useCallback(async () => { + if (!selectedProjectId) { + return; + } - const validation = validateCreationIntent( - creationIntentInput, - MIN_CREATION_INTENT_LENGTH, - ); - if (!validation.valid) { - setCreationIntentError(validation.message || "请完善创作意图"); - return; - } - - const initialUserPrompt = buildCreationIntentPrompt(creationIntentInput); - const creationIntentMetadata = buildCreationIntentMetadata( - creationIntentInput, - ); - - setCreatingContent(true); - try { - const defaultType = getDefaultContentTypeForProject( - theme as ProjectType, - ); - const created = await createContent({ - project_id: selectedProjectId, - title: `新${getContentTypeLabel(defaultType)}`, - content_type: defaultType, - metadata: { - creationMode: selectedCreationMode, - creationIntent: creationIntentMetadata, - }, - }); - - setContentCreationModes((previous) => ({ - ...previous, - [created.id]: selectedCreationMode, - })); - setPendingInitialPromptsByContentId((previous) => ({ - ...previous, - [created.id]: initialUserPrompt, - })); - setCreateContentDialogOpen(false); - resetCreateContentDialogState(); - await loadContents(selectedProjectId); - handleEnterWorkspace(created.id, { showChatPanel: true }); - toast.success("已创建新文稿"); - } catch (error) { - console.error("创建文稿失败:", error); - toast.error("创建文稿失败"); - } finally { - setCreatingContent(false); - } - }, - [ + const validation = validateCreationIntent( creationIntentInput, - handleEnterWorkspace, - loadContents, - resetCreateContentDialogState, - selectedCreationMode, - selectedProjectId, - theme, - ], - ); + MIN_CREATION_INTENT_LENGTH, + ); + if (!validation.valid) { + setCreationIntentError(validation.message || "请完善创作意图"); + return; + } + + const initialUserPrompt = buildCreationIntentPrompt(creationIntentInput); + const creationIntentMetadata = + buildCreationIntentMetadata(creationIntentInput); + + setCreatingContent(true); + try { + const defaultType = getDefaultContentTypeForProject(theme as ProjectType); + const created = await createContent({ + project_id: selectedProjectId, + title: `新${getContentTypeLabel(defaultType)}`, + content_type: defaultType, + metadata: { + creationMode: selectedCreationMode, + creationIntent: creationIntentMetadata, + }, + }); + + setContentCreationModes((previous) => ({ + ...previous, + [created.id]: selectedCreationMode, + })); + setPendingInitialPromptsByContentId((previous) => ({ + ...previous, + [created.id]: initialUserPrompt, + })); + setCreateContentDialogOpen(false); + resetCreateContentDialogState(); + await loadContents(selectedProjectId); + handleEnterWorkspace(created.id, { showChatPanel: true }); + toast.success("已创建新文稿"); + } catch (error) { + console.error("创建文稿失败:", error); + toast.error("创建文稿失败"); + } finally { + setCreatingContent(false); + } + }, [ + creationIntentInput, + handleEnterWorkspace, + loadContents, + resetCreateContentDialogState, + selectedCreationMode, + selectedProjectId, + theme, + ]); const consumePendingInitialPrompt = useCallback((contentId: string) => { setPendingInitialPromptsByContentId((previous) => { @@ -723,6 +729,13 @@ export function WorkbenchPage({ } }, [workspaceMode]); + useEffect(() => { + if (theme !== "video") { + return; + } + setVideoCanvasState(createInitialVideoState()); + }, [theme, resetAt]); + useEffect(() => { if (!workflowProgress || workflowProgress.steps.length === 0) { setShowWorkflowRail(false); @@ -1071,6 +1084,15 @@ export function WorkbenchPage({ }} /> ) + ) : workspaceMode === "workspace" && theme === "video" ? ( +
+ +
) : !selectedProjectId || !selectedContentId ? (
@@ -1128,33 +1150,35 @@ export function WorkbenchPage({ )} - {workspaceMode === "workspace" && activeRightDrawer === "tools" && ( - - )} + {workspaceMode === "workspace" && + theme !== "video" && + activeRightDrawer === "tools" && ( + + )} - {workspaceMode === "workspace" && ( + {workspaceMode === "workspace" && theme !== "video" && (