From be4c4641c254aa3dfec299388359e456bdbf8102 Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 2 Feb 2026 15:38:00 +0800 Subject: [PATCH] release: v0.52.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features ### Artifact 系统优化 - 修复代码块点击后右侧面板无法显示代码的问题 - 移除重复的「生成中...」状态指示器(保留右下角) - 优化代码块折叠卡片的点击交互,自动匹配对应 Artifact ### 会话历史持久化 - 新增 Aster SessionStore 实现,将会话数据存储到 ProxyCast 数据库 - 修复切换话题时无法加载历史消息的问题 - 统一 Aster 框架与 ProxyCast 的会话存储 ### AI 自动生成标题 - 新增 AI 自动生成会话标题功能 - 使用本地代理服务器调用 AI 生成简洁标题 - 失败时自动回退到简单截断方式 ### 流式状态优化 - 代码渲染器工具栏在流式生成时显示旋转加载图标 - Artifact 占位符卡片支持流式状态显示 - 统一流式状态的传递和显示逻辑 ## Bug Fixes - 修复 db.lock() 调用缺少错误处理的编译错误 - 修复 ArtifactToolbar 中未使用的变量警告 --- package.json | 4 +- src-tauri/Cargo.lock | 30 +- src-tauri/Cargo.toml | 6 +- src-tauri/src/agent/prompt/README.md | 65 ++ src-tauri/src/agent/prompt/builder.rs | 183 ++++++ src-tauri/src/agent/prompt/mod.rs | 14 + src-tauri/src/agent/prompt/templates.rs | 121 ++++ src-tauri/src/app/bootstrap.rs | 19 + src-tauri/src/app/runner.rs | 1 + src-tauri/src/commands/general_chat_cmd.rs | 160 +++++ src-tauri/src/services/aster_session_store.rs | 615 ++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/tauri.conf.json | 2 +- .../chat/components/ArtifactPlaceholder.tsx | 31 +- .../chat/components/MarkdownRenderer.tsx | 13 +- .../chat/components/StreamingRenderer.tsx | 2 + src/components/agent/chat/index.tsx | 69 +- src/components/artifact/ArtifactPanel.tsx | 30 + src/components/artifact/ArtifactRenderer.tsx | 12 + src/components/artifact/ArtifactToolbar.tsx | 152 ++++- .../artifact/renderers/CodeRenderer.tsx | 607 ++++++++++++++--- .../artifact/renderers/code-highlight.css | 143 ++++ .../general-chat/hooks/useSession.ts | 25 +- .../general-chat/store/useGeneralChatStore.ts | 33 + src/lib/artifact/types.ts | 10 + 25 files changed, 2189 insertions(+), 159 deletions(-) create mode 100644 src-tauri/src/agent/prompt/README.md create mode 100644 src-tauri/src/agent/prompt/builder.rs create mode 100644 src-tauri/src/agent/prompt/mod.rs create mode 100644 src-tauri/src/agent/prompt/templates.rs create mode 100644 src-tauri/src/services/aster_session_store.rs create mode 100644 src/components/artifact/renderers/code-highlight.css diff --git a/package.json b/package.json index cd10e0ef7..137edf94d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.51.0", + "version": "0.52.0", "type": "module", "repository": { "type": "git", @@ -65,6 +65,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.19", "fabric": "^5.5.2", + "hast-util-to-html": "^9.0.5", "i18next": "^25.7.3", "jotai": "^2.16.1", "lodash-es": "^4.17.22", @@ -77,6 +78,7 @@ "react-resizable-panels": "^4.3.0", "react-router-dom": "^7.11.0", "react-syntax-highlighter": "^16.1.0", + "refractor": "^5.0.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f13b51f48..2ef339810 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster" -version = "0.5.1" +version = "0.5.2" dependencies = [ "ahash", "anyhow", @@ -2112,7 +2112,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2128,7 +2128,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -3988,7 +3988,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -5307,7 +5307,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.114", @@ -6024,9 +6024,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -6035,7 +6033,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -6139,12 +6139,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6545,7 +6545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.51.0" +version = "0.52.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -6651,7 +6651,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.51.0" +version = "0.52.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7969,7 +7969,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 56478e788..9f15e018a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.51.0" +version = "0.52.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -103,9 +103,9 @@ enigo = "0.3" # Aster Agent Framework # 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库 # 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/) -# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.5.1" +# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.5.2" # aster = { version = "0.5.1", path = "../../../astercloud/aster-rust/crates/aster" } -aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.5.1" } +aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.5.2" } # Tauri diff --git a/src-tauri/src/agent/prompt/README.md b/src-tauri/src/agent/prompt/README.md new file mode 100644 index 000000000..ef4113a1f --- /dev/null +++ b/src-tauri/src/agent/prompt/README.md @@ -0,0 +1,65 @@ +# System Prompt 模块 + + + +## 架构说明 + +为 Aster Agent 提供 System Prompt 配置,参考 claude-code-open 的设计。 + +### 设计决策 + +- **模块化模板**:将 System Prompt 拆分为多个独立模板,便于维护和定制 +- **自动注入**:Agent 初始化时自动注入 System Prompt +- **环境感知**:自动添加当前日期、操作系统、工作目录等环境信息 + +## 文件索引 + +| 文件 | 说明 | +|------|------| +| `mod.rs` | 模块入口,导出公共类型 | +| `templates.rs` | 提示词模板定义 | +| `builder.rs` | 提示词构建器 | + +## 模板内容 + +| 模板 | 说明 | +|------|------| +| `CORE_IDENTITY` | Agent 身份描述 | +| `TOOL_GUIDELINES` | 工具使用策略(read/write/edit/glob/grep/bash) | +| `CODING_GUIDELINES` | 代码编写指南 | +| `TASK_MANAGEMENT` | 任务管理(TodoWrite 使用) | +| `GIT_GUIDELINES` | Git 操作安全规则 | +| `OUTPUT_STYLE` | 输出风格指南 | + +## 使用方式 + +### 基本使用 + +```rust +use crate::agent::prompt::SystemPromptBuilder; + +let prompt = SystemPromptBuilder::new() + .working_dir("/path/to/project") + .build(); +``` + +### 添加自定义指令 + +```rust +let prompt = SystemPromptBuilder::new() + .working_dir("/path/to/project") + .custom_instructions("额外的项目特定指令") + .build(); +``` + +### 在 AsterAgentState 中的集成 + +System Prompt 在 `init_agent()` 时自动注入: + +```rust +// 初始化时自动注入 System Prompt +state.init_agent().await?; + +// 也可以动态添加自定义指令 +state.add_custom_instructions("额外指令").await?; +``` diff --git a/src-tauri/src/agent/prompt/builder.rs b/src-tauri/src/agent/prompt/builder.rs new file mode 100644 index 000000000..a1f25bbe2 --- /dev/null +++ b/src-tauri/src/agent/prompt/builder.rs @@ -0,0 +1,183 @@ +//! System Prompt 构建器 +//! +//! 组装完整的模块化系统提示词 + +use super::templates::*; +use chrono::Utc; +use std::path::Path; + +/// System Prompt 构建选项 +#[derive(Debug, Clone, Default)] +pub struct SystemPromptOptions { + /// 是否包含核心身份 + pub include_identity: bool, + /// 是否包含工具指南 + pub include_tool_guidelines: bool, + /// 是否包含代码指南 + pub include_coding_guidelines: bool, + /// 是否包含任务管理指南 + pub include_task_management: bool, + /// 是否包含 Git 指南 + pub include_git_guidelines: bool, + /// 是否包含输出风格指南 + pub include_output_style: bool, + /// 工作目录 + pub working_dir: Option, + /// 自定义指令 + pub custom_instructions: Option, +} + +impl SystemPromptOptions { + /// 创建默认选项(包含所有指南) + pub fn default_all() -> Self { + Self { + include_identity: true, + include_tool_guidelines: true, + include_coding_guidelines: true, + include_task_management: true, + include_git_guidelines: true, + include_output_style: true, + working_dir: None, + custom_instructions: None, + } + } +} + + +/// System Prompt 构建器 +pub struct SystemPromptBuilder { + options: SystemPromptOptions, +} + +impl Default for SystemPromptBuilder { + fn default() -> Self { + Self::new() + } +} + +impl SystemPromptBuilder { + /// 创建新的构建器 + pub fn new() -> Self { + Self { + options: SystemPromptOptions::default_all(), + } + } + + /// 使用自定义选项创建构建器 + pub fn with_options(options: SystemPromptOptions) -> Self { + Self { options } + } + + /// 设置工作目录 + pub fn working_dir(mut self, dir: impl AsRef) -> Self { + self.options.working_dir = Some(dir.as_ref().to_string_lossy().to_string()); + self + } + + /// 添加自定义指令 + pub fn custom_instructions(mut self, instructions: impl Into) -> Self { + self.options.custom_instructions = Some(instructions.into()); + self + } + + /// 构建完整的 System Prompt + pub fn build(&self) -> String { + let mut parts: Vec<&str> = Vec::new(); + + // 1. 核心身份 + if self.options.include_identity { + parts.push(CORE_IDENTITY); + } + + // 2. 工具使用指南 + if self.options.include_tool_guidelines { + parts.push(TOOL_GUIDELINES); + } + + // 3. 代码编写指南 + if self.options.include_coding_guidelines { + parts.push(CODING_GUIDELINES); + } + + // 4. 任务管理指南 + if self.options.include_task_management { + parts.push(TASK_MANAGEMENT); + } + + // 5. Git 操作指南 + if self.options.include_git_guidelines { + parts.push(GIT_GUIDELINES); + } + + // 6. 输出风格指南 + if self.options.include_output_style { + parts.push(OUTPUT_STYLE); + } + + let mut prompt = parts.join("\n\n"); + + // 添加环境信息 + let env_info = self.build_environment_info(); + if !env_info.is_empty() { + prompt.push_str("\n\n"); + prompt.push_str(&env_info); + } + + // 添加自定义指令 + if let Some(ref custom) = self.options.custom_instructions { + prompt.push_str("\n\n# 附加指令\n\n"); + prompt.push_str(custom); + } + + prompt + } + + + /// 构建环境信息部分 + fn build_environment_info(&self) -> String { + let mut info = String::from("# 环境信息\n\n"); + + // 当前日期时间 + let now = Utc::now(); + info.push_str(&format!("- 当前日期: {}\n", now.format("%Y-%m-%d"))); + + // 操作系统 + info.push_str(&format!("- 操作系统: {}\n", std::env::consts::OS)); + + // 工作目录 + if let Some(ref dir) = self.options.working_dir { + info.push_str(&format!("- 工作目录: {}\n", dir)); + } + + info + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_default_prompt() { + let prompt = SystemPromptBuilder::new().build(); + assert!(prompt.contains("ProxyCast Agent")); + assert!(prompt.contains("工具使用策略")); + assert!(prompt.contains("代码编写指南")); + } + + #[test] + fn test_build_with_custom_instructions() { + let prompt = SystemPromptBuilder::new() + .custom_instructions("这是自定义指令") + .build(); + assert!(prompt.contains("这是自定义指令")); + } + + #[test] + fn test_build_with_working_dir() { + let prompt = SystemPromptBuilder::new() + .working_dir("/tmp/test") + .build(); + assert!(prompt.contains("/tmp/test")); + } +} diff --git a/src-tauri/src/agent/prompt/mod.rs b/src-tauri/src/agent/prompt/mod.rs new file mode 100644 index 000000000..0145759df --- /dev/null +++ b/src-tauri/src/agent/prompt/mod.rs @@ -0,0 +1,14 @@ +//! System Prompt 模块 +//! +//! 为 Aster Agent 提供 System Prompt 配置 +//! 参考 claude-code-open 的设计,提供模块化的提示词组件 +//! +//! ## 模块结构 +//! - templates - 提示词模板定义 +//! - builder - 提示词构建器 + +pub mod templates; +pub mod builder; + +pub use builder::SystemPromptBuilder; +pub use templates::*; diff --git a/src-tauri/src/agent/prompt/templates.rs b/src-tauri/src/agent/prompt/templates.rs new file mode 100644 index 000000000..ba9136bca --- /dev/null +++ b/src-tauri/src/agent/prompt/templates.rs @@ -0,0 +1,121 @@ +//! System Prompt 模板定义 +//! +//! 模块化的提示词组件,参考 claude-code-open 设计 + +/// 核心身份描述 +pub const CORE_IDENTITY: &str = r#"你是 ProxyCast Agent,一个强大的 AI 编程助手。 + +你可以使用各种工具来帮助用户完成编程任务,包括: +- 读取和编辑文件 +- 执行 shell 命令 +- 搜索代码库 +- 管理任务列表 + +重要安全规则: +- 只协助授权的安全测试、防御性安全、CTF 挑战和教育场景 +- 拒绝破坏性技术、DoS 攻击、大规模攻击、供应链攻击的请求 +- 永远不要生成或猜测 URL,除非你确信这些 URL 是用于帮助用户编程"#; + + +/// 工具使用指南 +pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 + +## 可用工具 + +你有以下工具可以使用: + +### 文件操作工具 +- **read**: 读取文件内容(支持文本、图片、PDF、notebook) +- **write**: 创建或覆盖文件 +- **edit**: 智能编辑文件(推荐用于修改现有文件) + +### 搜索工具 +- **glob**: 使用 glob 模式搜索文件路径 +- **grep**: 使用正则表达式搜索文件内容 + +### 系统工具 +- **bash**: 执行 shell 命令 + +### 任务管理工具 +- **TodoWrite**: 创建和管理任务列表 + +## 使用原则 + +1. **优先使用专用工具**:文件操作使用 read/write/edit,不要用 bash 的 cat/echo +2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用 +3. **先读后改**:修改文件前必须先读取文件内容 +4. **最小权限**:只执行必要的操作,避免不必要的文件修改"#; + + +/// 代码编写指南 +pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 + +## 基本原则 + +1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构 +2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤 +3. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等) +4. **避免过度工程**:只做必要的修改,保持解决方案简单 + +## 代码质量 + +- 不要添加未被请求的功能或重构 +- 不要添加不必要的注释、文档字符串或类型注解 +- 不要为不可能发生的场景添加错误处理 +- 三行相似的代码比过早的抽象更好 + +## 文件操作 + +- 永远不要创建不必要的文件 +- 优先编辑现有文件而不是创建新文件 +- 删除未使用的代码,不要留下注释掉的代码"#; + + +/// 任务管理指南 +pub const TASK_MANAGEMENT: &str = r#"# 任务管理 + +你可以使用 TodoWrite 工具来管理和规划任务。频繁使用这个工具来: +- 跟踪你的任务进度 +- 让用户了解你的工作状态 +- 将复杂任务分解为小步骤 + +## 使用示例 + +当用户请求一个复杂任务时: +1. 先用 TodoWrite 创建任务列表 +2. 开始执行第一个任务 +3. 完成后立即标记为已完成 +4. 继续下一个任务 + +不要批量完成多个任务后再标记,应该完成一个标记一个。"#; + + +/// Git 操作指南 +pub const GIT_GUIDELINES: &str = r#"# Git 操作 + +- 永远不要更新 git config +- 永远不要运行破坏性/不可逆的 git 命令(如 push --force, hard reset),除非明确请求 +- 永远不要跳过 hooks(--no-verify),除非明确请求 +- 永远不要强制推送到 main/master +- 避免使用 git commit --amend,除非明确请求 +- 在 amend 之前:始终检查作者信息(git log -1 --format='%an %ae') +- 永远不要提交更改,除非用户明确要求"#; + + +/// 输出风格指南 +pub const OUTPUT_STYLE: &str = r#"# 输出风格 + +## 格式要求 +- 使用 Markdown 格式 +- 代码块使用三个反引号,并标注语言 +- 保持简洁,避免冗长的解释 + +## 专业客观 +- 优先考虑技术准确性和真实性 +- 专注于事实和问题解决 +- 提供直接、客观的技术信息 +- 避免过度赞美或情感验证 + +## 规划时不要估计时间 +- 提供具体的实现步骤,但不要估计时间 +- 专注于需要做什么,而不是什么时候做"#; diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 66f3225ae..10edd70f0 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -37,6 +37,7 @@ use crate::logger; use crate::plugin; use crate::server; use crate::services::api_key_provider_service::ApiKeyProviderService; +use crate::services::aster_session_store::ProxyCastSessionStore; use crate::services::context_memory_service::{ContextMemoryConfig, ContextMemoryService}; use crate::services::provider_pool_service::ProviderPoolService; use crate::services::skill_service::SkillService; @@ -219,6 +220,24 @@ pub fn init_states(config: &Config) -> Result { ) = init_flow_monitor(&provider_pool_service_state, &db, &plugin_installer_state)?; // 其他状态 + // 设置 Aster 全局 session store(使用 ProxyCast 数据库) + let session_store = Arc::new(ProxyCastSessionStore::new(db.clone())); + // 使用 tokio runtime 来设置全局 store + let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| { + // 如果没有 runtime,创建一个临时的 + tokio::runtime::Runtime::new().unwrap().handle().clone() + }); + rt.block_on(async { + if let Err(e) = aster::session::set_global_session_store(session_store).await { + tracing::warn!( + "[Bootstrap] 设置全局 session store 失败(可能已设置): {}", + e + ); + } else { + tracing::info!("[Bootstrap] 已设置 Aster 全局 session store"); + } + }); + let aster_agent_state = AsterAgentState::new(); let orchestrator_state = OrchestratorState::new(); diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index bad140d03..0b59758c2 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1227,6 +1227,7 @@ pub fn run() { commands::general_chat_cmd::general_chat_add_message, commands::general_chat_cmd::general_chat_send_message, commands::general_chat_cmd::general_chat_stop_generation, + commands::general_chat_cmd::general_chat_generate_title, // Workspace commands commands::workspace_cmd::workspace_create, commands::workspace_cmd::workspace_list, diff --git a/src-tauri/src/commands/general_chat_cmd.rs b/src-tauri/src/commands/general_chat_cmd.rs index 6554d0f8f..657dda4e9 100644 --- a/src-tauri/src/commands/general_chat_cmd.rs +++ b/src-tauri/src/commands/general_chat_cmd.rs @@ -402,3 +402,163 @@ pub async fn general_chat_stop_generation(session_id: String) -> Result, + /// 模型名称(可选) + #[serde(default)] + pub model: Option, +} + +/// 自动生成会话标题 +/// +/// 基于用户第一条消息,调用 AI 生成简短的会话标题 +/// +/// # Arguments +/// * `request` - 生成标题请求 +#[tauri::command] +pub async fn general_chat_generate_title( + db: State<'_, DbConnection>, + request: GenerateTitleRequest, +) -> Result { + tracing::info!( + "[GeneralChat] 生成标题: session={}, message_len={}", + request.session_id, + request.first_message.len() + ); + + // 生成标题的 prompt + let prompt = format!( + "请根据以下用户消息,生成一个简短的对话标题(不超过15个字符,不要使用引号,直接输出标题):\n\n{}", + request.first_message.chars().take(500).collect::() + ); + + // 尝试调用 AI 生成标题 + let title = match generate_title_with_ai(&prompt).await { + Ok(ai_title) => { + tracing::info!("[GeneralChat] AI 生成标题成功: {}", ai_title); + // 清理 AI 返回的标题(去除引号、换行等) + clean_title(&ai_title) + } + Err(e) => { + tracing::warn!("[GeneralChat] AI 生成标题失败,使用 fallback: {}", e); + // Fallback: 使用简单的截取逻辑 + generate_fallback_title(&request.first_message) + } + }; + + // 更新数据库中的会话标题 + { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + GeneralChatDao::rename_session(&conn, &request.session_id, &title) + .map_err(|e| format!("更新标题失败: {}", e))?; + } + + tracing::info!( + "[GeneralChat] 标题生成完成: session={}, title={}", + request.session_id, + title + ); + + Ok(title) +} + +/// 使用 AI 生成标题 +async fn generate_title_with_ai(prompt: &str) -> Result { + use crate::models::openai::{ChatCompletionRequest, ChatMessage, MessageContent}; + use crate::providers::openai_custom::OpenAICustomProvider; + + // 使用本地代理服务器调用 AI + // 这样可以利用已配置的凭证池 + let provider = OpenAICustomProvider::with_config( + "local".to_string(), + Some("http://127.0.0.1:5678".to_string()), + ); + + let request = ChatCompletionRequest { + model: "default".to_string(), // 使用默认模型 + messages: vec![ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::Text(prompt.to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }], + temperature: Some(0.3), + max_tokens: Some(32), + top_p: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_effort: None, + }; + + let resp = provider + .call_api(&request) + .await + .map_err(|e| format!("API 调用失败: {}", e))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + if !status.is_success() { + return Err(format!("API 返回错误: {} - {}", status, body)); + } + + let parsed: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("解析响应失败: {}", e))?; + + let content = parsed["choices"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|c| c["message"]["content"].as_str()) + .unwrap_or("") + .to_string(); + + if content.is_empty() { + return Err("AI 返回空内容".to_string()); + } + + Ok(content) +} + +/// 清理 AI 生成的标题 +fn clean_title(title: &str) -> String { + let cleaned = title + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim_matches('「') + .trim_matches('」') + .trim_matches('《') + .trim_matches('》') + .lines() + .next() + .unwrap_or(title) + .trim(); + + // 限制长度 + if cleaned.chars().count() > 20 { + format!("{}...", cleaned.chars().take(17).collect::()) + } else { + cleaned.to_string() + } +} + +/// 生成 fallback 标题 +fn generate_fallback_title(message: &str) -> String { + let trimmed = message.trim(); + if trimmed.chars().count() > 20 { + format!("{}...", trimmed.chars().take(17).collect::()) + } else { + trimmed.to_string() + } +} diff --git a/src-tauri/src/services/aster_session_store.rs b/src-tauri/src/services/aster_session_store.rs new file mode 100644 index 000000000..985c842c3 --- /dev/null +++ b/src-tauri/src/services/aster_session_store.rs @@ -0,0 +1,615 @@ +//! Aster SessionStore 实现 +//! +//! 实现 aster::session::SessionStore trait,将 aster 的会话数据 +//! 存储到 ProxyCast 的 SQLite 数据库中。 +//! +//! 这是应用层接管框架层存储的关键桥接模块。 + +use crate::database::DbConnection; +use anyhow::{anyhow, Result}; +use aster::conversation::message::{Message, MessageContent}; +use aster::conversation::Conversation; +use aster::model::ModelConfig; +use aster::recipe::Recipe; +use aster::session::extension_data::ExtensionData; +use aster::session::{ + ChatHistoryMatch, Session, SessionInsights, SessionStore, SessionType, TokenStatsUpdate, +}; +use async_trait::async_trait; +use chrono::Utc; +use std::collections::HashMap; +use std::path::PathBuf; + +/// ProxyCast 的 SessionStore 实现 +/// +/// 将 aster 的会话数据存储到 ProxyCast 的 SQLite 数据库 +pub struct ProxyCastSessionStore { + db: DbConnection, +} + +impl ProxyCastSessionStore { + /// 创建新的 SessionStore 实例 + pub fn new(db: DbConnection) -> Self { + Self { db } + } + + /// 将 Message 的 role 转换为字符串 + /// 通过检查 Message::user() 和 Message::assistant() 的 role 来判断 + fn message_role_to_string(message: &Message) -> String { + // 使用 Debug 格式来获取 role 字符串 + let role_debug = format!("{:?}", message.role); + if role_debug.contains("User") { + "user".to_string() + } else { + "assistant".to_string() + } + } +} + +#[async_trait] +impl SessionStore for ProxyCastSessionStore { + async fn create_session( + &self, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + let type_str = session_type.to_string(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![id, type_str, None::, name, now_str, now_str], + ) + .map_err(|e| anyhow!("创建会话失败: {}", e))?; + + Ok(Session { + id, + working_dir, + name, + user_set_name: false, + session_type, + created_at: now, + updated_at: now, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: Some(Conversation::default()), + message_count: 0, + provider_name: None, + model_config: None, + }) + } + + async fn get_session(&self, id: &str, include_messages: bool) -> Result { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, model, system_prompt, title, created_at, updated_at + FROM agent_sessions WHERE id = ?", + ) + .map_err(|e| anyhow!("准备查询失败: {}", e))?; + + let session_row = stmt + .query_row([id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }) + .map_err(|e| anyhow!("会话不存在: {}", e))?; + + let (id, model, _system_prompt, title, created_at, updated_at) = session_row; + + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + let session_type = model.parse().unwrap_or(SessionType::User); + + let conversation = if include_messages { + Some(self.load_conversation(&conn, &id)?) + } else { + None + }; + + let message_count = self.count_messages(&conn, &id)?; + + Ok(Session { + id: id.to_string(), + working_dir: PathBuf::from("."), + name: title.unwrap_or_else(|| "未命名会话".to_string()), + user_set_name: false, + session_type, + created_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation, + message_count, + provider_name: None, + model_config: None, + }) + } + + async fn add_message(&self, session_id: &str, message: &Message) -> Result<()> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + let role = Self::message_role_to_string(message); + let content_json = serde_json::to_string(&message.content) + .map_err(|e| anyhow!("序列化消息内容失败: {}", e))?; + let timestamp = Utc::now().to_rfc3339(); + + // 从 content 中提取 tool_calls(ToolRequest 类型) + let tool_requests: Vec<_> = message + .content + .iter() + .filter_map(|c| { + if let MessageContent::ToolRequest(req) = c { + Some(req.clone()) + } else { + None + } + }) + .collect(); + + let tool_calls_json: Option = if !tool_requests.is_empty() { + Some(serde_json::to_string(&tool_requests)?) + } else { + None + }; + + // 从 content 中提取 tool_call_id(ToolResponse 类型) + let tool_call_id: Option = message.content.iter().find_map(|c| { + if let MessageContent::ToolResponse(resp) = c { + Some(resp.id.clone()) + } else { + None + } + }); + + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp, tool_calls_json, tool_call_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![session_id, role, content_json, timestamp, tool_calls_json, tool_call_id], + ) + .map_err(|e| anyhow!("添加消息失败: {}", e))?; + + conn.execute( + "UPDATE agent_sessions SET updated_at = ? WHERE id = ?", + rusqlite::params![timestamp, session_id], + ) + .map_err(|e| anyhow!("更新会话时间失败: {}", e))?; + + Ok(()) + } + + async fn replace_conversation( + &self, + session_id: &str, + conversation: &Conversation, + ) -> Result<()> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + conn.execute( + "DELETE FROM agent_messages WHERE session_id = ?", + [session_id], + ) + .map_err(|e| anyhow!("删除旧消息失败: {}", e))?; + + for message in conversation.messages() { + let role = Self::message_role_to_string(message); + let content_json = serde_json::to_string(&message.content)?; + let timestamp = Utc::now().to_rfc3339(); + + let tool_requests: Vec<_> = message + .content + .iter() + .filter_map(|c| { + if let MessageContent::ToolRequest(req) = c { + Some(req.clone()) + } else { + None + } + }) + .collect(); + + let tool_calls_json: Option = if !tool_requests.is_empty() { + Some(serde_json::to_string(&tool_requests)?) + } else { + None + }; + + let tool_call_id: Option = message.content.iter().find_map(|c| { + if let MessageContent::ToolResponse(resp) = c { + Some(resp.id.clone()) + } else { + None + } + }); + + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp, tool_calls_json, tool_call_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![session_id, role, content_json, timestamp, tool_calls_json, tool_call_id], + )?; + } + + let now = Utc::now().to_rfc3339(); + conn.execute( + "UPDATE agent_sessions SET updated_at = ? WHERE id = ?", + rusqlite::params![now, session_id], + )?; + + Ok(()) + } + + async fn list_sessions(&self) -> Result> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + let mut stmt = conn.prepare( + "SELECT id, model, system_prompt, title, created_at, updated_at + FROM agent_sessions ORDER BY updated_at DESC", + )?; + + let sessions: Vec = stmt + .query_map([], |row| { + let id: String = row.get(0)?; + let model: String = row.get(1)?; + let title: Option = row.get(3)?; + let created_at: String = row.get(4)?; + let updated_at: String = row.get(5)?; + + Ok((id, model, title, created_at, updated_at)) + })? + .filter_map(|r| r.ok()) + .map(|(id, model, title, created_at, updated_at)| { + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let session_type = model.parse().unwrap_or(SessionType::User); + + Session { + id, + working_dir: PathBuf::from("."), + name: title.unwrap_or_else(|| "未命名会话".to_string()), + user_set_name: false, + session_type, + created_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + } + }) + .collect(); + + Ok(sessions) + } + + async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result> { + let all_sessions = self.list_sessions().await?; + Ok(all_sessions + .into_iter() + .filter(|s| types.contains(&s.session_type)) + .collect()) + } + + async fn delete_session(&self, id: &str) -> Result<()> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + conn.execute("DELETE FROM agent_sessions WHERE id = ?", [id])?; + Ok(()) + } + + async fn get_insights(&self) -> Result { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + let total_sessions: i64 = + conn.query_row("SELECT COUNT(*) FROM agent_sessions", [], |row| row.get(0))?; + + Ok(SessionInsights { + total_sessions: total_sessions as usize, + total_tokens: 0, + }) + } + + async fn export_session(&self, id: &str) -> Result { + let session = self.get_session(id, true).await?; + serde_json::to_string_pretty(&session).map_err(|e| anyhow!("导出会话失败: {}", e)) + } + + async fn import_session(&self, json: &str) -> Result { + let session: Session = + serde_json::from_str(json).map_err(|e| anyhow!("解析会话 JSON 失败: {}", e))?; + + let new_session = self + .create_session( + session.working_dir.clone(), + session.name.clone(), + session.session_type, + ) + .await?; + + if let Some(conversation) = &session.conversation { + self.replace_conversation(&new_session.id, conversation) + .await?; + } + + Ok(new_session) + } + + async fn copy_session(&self, session_id: &str, new_name: String) -> Result { + let original = self.get_session(session_id, true).await?; + + let new_session = self + .create_session( + original.working_dir.clone(), + new_name, + original.session_type, + ) + .await?; + + if let Some(conversation) = &original.conversation { + self.replace_conversation(&new_session.id, conversation) + .await?; + } + + Ok(new_session) + } + + async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + + let dt = + chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_else(|| Utc::now().into()); + let timestamp_str = dt.to_rfc3339(); + + conn.execute( + "DELETE FROM agent_messages WHERE session_id = ? AND timestamp > ?", + rusqlite::params![session_id, timestamp_str], + )?; + + Ok(()) + } + + async fn update_session_name( + &self, + session_id: &str, + name: String, + _user_set: bool, + ) -> Result<()> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + conn.execute( + "UPDATE agent_sessions SET title = ? WHERE id = ?", + rusqlite::params![name, session_id], + )?; + Ok(()) + } + + async fn update_extension_data( + &self, + _session_id: &str, + _extension_data: ExtensionData, + ) -> Result<()> { + Ok(()) + } + + async fn update_token_stats(&self, _session_id: &str, _stats: TokenStatsUpdate) -> Result<()> { + Ok(()) + } + + async fn update_provider_config( + &self, + session_id: &str, + provider_name: Option, + _model_config: Option, + ) -> Result<()> { + if let Some(provider) = provider_name { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + conn.execute( + "UPDATE agent_sessions SET model = ? WHERE id = ?", + rusqlite::params![provider, session_id], + )?; + } + Ok(()) + } + + async fn update_recipe( + &self, + _session_id: &str, + _recipe: Option, + _user_recipe_values: Option>, + ) -> Result<()> { + Ok(()) + } + + async fn search_chat_history( + &self, + query: &str, + limit: Option, + _after_date: Option>, + _before_date: Option>, + _exclude_session_id: Option, + ) -> Result> { + let conn = self + .db + .lock() + .map_err(|e| anyhow!("数据库锁定失败: {}", e))?; + let limit = limit.unwrap_or(50); + + let mut stmt = conn.prepare( + "SELECT m.session_id, s.title, m.role, m.content_json, m.timestamp + FROM agent_messages m + JOIN agent_sessions s ON m.session_id = s.id + WHERE m.content_json LIKE ? + ORDER BY m.timestamp DESC + LIMIT ?", + )?; + + let pattern = format!("%{}%", query); + let matches: Vec = stmt + .query_map(rusqlite::params![pattern, limit as i64], |row| { + let session_id: String = row.get(0)?; + let session_name: Option = row.get(1)?; + let role: String = row.get(2)?; + let content_json: String = row.get(3)?; + let timestamp: String = row.get(4)?; + + Ok((session_id, session_name, role, content_json, timestamp)) + })? + .filter_map(|r| r.ok()) + .map( + |(session_id, session_name, role, content_json, timestamp)| { + let timestamp = chrono::DateTime::parse_from_rfc3339(×tamp) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + ChatHistoryMatch { + session_id, + session_name: session_name.unwrap_or_else(|| "未命名".to_string()), + message_role: role, + message_content: content_json, + timestamp, + relevance_score: 1.0, + } + }, + ) + .collect(); + + Ok(matches) + } +} + +// ============================================================================ +// 辅助方法 +// ============================================================================ + +impl ProxyCastSessionStore { + /// 加载会话的对话历史 + fn load_conversation( + &self, + conn: &rusqlite::Connection, + session_id: &str, + ) -> Result { + let mut stmt = conn.prepare( + "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id + FROM agent_messages WHERE session_id = ? ORDER BY id ASC", + )?; + + let messages: Vec = stmt + .query_map([session_id], |row| { + let role: String = row.get(0)?; + let content_json: String = row.get(1)?; + let _timestamp: String = row.get(2)?; + let _tool_calls_json: Option = row.get(3)?; + let _tool_call_id: Option = row.get(4)?; + + Ok((role, content_json)) + })? + .filter_map(|r| r.ok()) + .filter_map(|(role, content_json)| { + // 尝试解析消息内容 + let content: Vec = serde_json::from_str(&content_json).ok()?; + + // 根据角色创建消息 + let mut message = if role == "assistant" { + Message::assistant() + } else { + Message::user() + }; + + // 添加所有内容 + for c in content { + message = message.with_content(c); + } + + Some(message) + }) + .collect(); + + Ok(Conversation::new_unvalidated(messages)) + } + + /// 统计会话消息数量 + fn count_messages(&self, conn: &rusqlite::Connection, session_id: &str) -> Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM agent_messages WHERE session_id = ?", + [session_id], + |row| row.get(0), + )?; + Ok(count as usize) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 1dfe580b9..8eb69b771 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod api_key_provider_service; +pub mod aster_session_store; pub mod backup_service; pub mod context_memory_service; pub mod file_browser_service; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9523d19fe..9878388da 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.51.0", + "version": "0.52.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/components/ArtifactPlaceholder.tsx b/src/components/agent/chat/components/ArtifactPlaceholder.tsx index 15a231466..1df606cb9 100644 --- a/src/components/agent/chat/components/ArtifactPlaceholder.tsx +++ b/src/components/agent/chat/components/ArtifactPlaceholder.tsx @@ -5,8 +5,13 @@ */ import React, { memo } from "react"; -import { FileCode, ExternalLink } from "lucide-react"; -import styled from "styled-components"; +import { FileCode, ExternalLink, Loader2 } from "lucide-react"; +import styled, { keyframes } from "styled-components"; + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; const PlaceholderCard = styled.div` display: flex; @@ -55,6 +60,13 @@ const Subtitle = styled.div` font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 2px; + display: flex; + align-items: center; + gap: 6px; +`; + +const SpinningLoader = styled(Loader2)` + animation: ${spin} 1s linear infinite; `; const ActionIcon = styled.div` @@ -71,6 +83,8 @@ interface ArtifactPlaceholderProps { language: string; /** 代码行数 */ lineCount?: number; + /** 是否正在流式生成 */ + isStreaming?: boolean; /** 点击回调 */ onClick?: () => void; } @@ -118,7 +132,7 @@ function getLanguageDisplayName(language: string): string { * 在聊天消息中显示代码块的简洁卡片 */ export const ArtifactPlaceholder: React.FC = memo( - ({ language, lineCount, onClick }) => { + ({ language, lineCount, isStreaming = false, onClick }) => { const displayName = getLanguageDisplayName(language); return ( @@ -129,7 +143,16 @@ export const ArtifactPlaceholder: React.FC = memo( {displayName} 代码 - {lineCount ? `${lineCount} 行` : "点击在画布中查看"} + {isStreaming ? ( + <> + + 生成中... + + ) : lineCount ? ( + `${lineCount} 行` + ) : ( + "点击在画布中查看" + )} diff --git a/src/components/agent/chat/components/MarkdownRenderer.tsx b/src/components/agent/chat/components/MarkdownRenderer.tsx index aec538224..6f5b52fad 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.tsx @@ -266,10 +266,18 @@ interface MarkdownRendererProps { collapseCodeBlocks?: boolean; /** 代码块点击回调(用于在画布中显示) */ onCodeBlockClick?: (language: string, code: string) => void; + /** 是否正在流式生成 */ + isStreaming?: boolean; } export const MarkdownRenderer: React.FC = memo( - ({ content, onA2UISubmit, collapseCodeBlocks = false, onCodeBlockClick }) => { + ({ + content, + onA2UISubmit, + collapseCodeBlocks = false, + onCodeBlockClick, + isStreaming = false, + }) => { const [copied, setCopied] = React.useState(null); const handleCopy = (code: string) => { @@ -479,7 +487,8 @@ export const MarkdownRenderer: React.FC = memo( return ( onCodeBlockClick?.(language, codeContent)} /> ); diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index d5b222e4c..b085275a2 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -206,6 +206,7 @@ const StreamingText: React.FC = memo( onA2UISubmit={onA2UISubmit} collapseCodeBlocks={collapseCodeBlocks} onCodeBlockClick={onCodeBlockClick} + isStreaming={isStreaming} /> ); } @@ -256,6 +257,7 @@ const StreamingText: React.FC = memo( onA2UISubmit={onA2UISubmit} collapseCodeBlocks={collapseCodeBlocks} onCodeBlockClick={onCodeBlockClick} + isStreaming={isStreaming} /> ); } diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 545564186..315d92d7d 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -31,9 +31,13 @@ import { type CanvasState as GeneralCanvasState, DEFAULT_CANVAS_STATE, } from "@/components/general-chat/types"; -import { artifactsAtom, selectedArtifactAtom } from "@/lib/artifact/store"; +import { + artifactsAtom, + selectedArtifactAtom, + selectedArtifactIdAtom, +} from "@/lib/artifact/store"; import { ArtifactRenderer, ArtifactToolbar } from "@/components/artifact"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser"; import { @@ -200,6 +204,15 @@ export function AgentChatPage({ // Artifact 状态 - 用于在画布中显示 const artifacts = useAtomValue(artifactsAtom); const selectedArtifact = useAtomValue(selectedArtifactAtom); + const setSelectedArtifactId = useSetAtom(selectedArtifactIdAtom); + + // Artifact 预览状态 + const [artifactViewMode, setArtifactViewMode] = useState< + "source" | "preview" + >("source"); + const [artifactPreviewSize, setArtifactPreviewSize] = useState< + "mobile" | "tablet" | "desktop" + >("desktop"); // 当有新的 artifact 时,自动打开画布 useEffect(() => { @@ -927,20 +940,36 @@ export function AgentChatPage({ ); // 处理代码块点击 - 在画布中显示代码(General 主题专用) - const handleCodeBlockClick = useCallback((language: string, code: string) => { - console.log("[AgentChatPage] 代码块点击:", language); + const handleCodeBlockClick = useCallback( + (language: string, code: string) => { + console.log("[AgentChatPage] 代码块点击:", language); - // 使用 General 画布显示代码 - setGeneralCanvasState({ - isOpen: true, - contentType: "code", - content: code, - language: language || "text", - filename: `代码片段.${language || "txt"}`, - isEditing: false, - }); - setLayoutMode("chat-canvas"); - }, []); + // 尝试找到匹配的 artifact(根据内容匹配) + const matchingArtifact = artifacts.find((a) => a.content === code); + + if (matchingArtifact) { + // 如果找到匹配的 artifact,选中它 + console.log( + "[AgentChatPage] 找到匹配的 artifact:", + matchingArtifact.id, + ); + setSelectedArtifactId(matchingArtifact.id); + } else { + // 如果没有匹配的 artifact,使用 General 画布显示代码 + console.log("[AgentChatPage] 未找到匹配的 artifact,使用 General 画布"); + setGeneralCanvasState({ + isOpen: true, + contentType: "code", + content: code, + language: language || "text", + filename: `代码片段.${language || "txt"}`, + isEditing: false, + }); + } + setLayoutMode("chat-canvas"); + }, + [artifacts, setSelectedArtifactId], + ); // 判断是否应该折叠代码块(当画布打开且有 artifact 时) const shouldCollapseCodeBlocks = useMemo(() => { @@ -1139,12 +1168,20 @@ export function AgentChatPage({ {/* 渲染区域 */}
@@ -1189,6 +1226,8 @@ export function AgentChatPage({ mappedTheme, handleCloseCanvas, isSending, + artifactViewMode, + artifactPreviewSize, ]); // ========== 渲染逻辑 ========== diff --git a/src/components/artifact/ArtifactPanel.tsx b/src/components/artifact/ArtifactPanel.tsx index 854aeb5bb..785002dbd 100644 --- a/src/components/artifact/ArtifactPanel.tsx +++ b/src/components/artifact/ArtifactPanel.tsx @@ -150,6 +150,10 @@ export const ArtifactPanel: React.FC = memo( const [isResizing, setIsResizing] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); const [showSource, setShowSource] = useState(false); + const [viewMode, setViewMode] = useState<"source" | "preview">("source"); + const [previewSize, setPreviewSize] = useState< + "mobile" | "tablet" | "desktop" + >("desktop"); // Refs const panelRef = useRef(null); @@ -264,6 +268,7 @@ export const ArtifactPanel: React.FC = memo( (id: string) => { dispatch({ type: "select", id }); setShowSource(false); // 切换 artifact 时重置源码视图 + setViewMode("source"); // 重置视图模式 }, [dispatch], ); @@ -275,6 +280,23 @@ export const ArtifactPanel: React.FC = memo( setShowSource((prev) => !prev); }, []); + /** + * 切换视图模式(代码预览) + */ + const handleViewModeChange = useCallback((mode: "source" | "preview") => { + setViewMode(mode); + }, []); + + /** + * 切换预览尺寸 + */ + const handlePreviewSizeChange = useCallback( + (size: "mobile" | "tablet" | "desktop") => { + setPreviewSize(size); + }, + [], + ); + /** * 关闭面板 */ @@ -366,12 +388,20 @@ export const ArtifactPanel: React.FC = memo( showSource={showSource} onToggleSource={handleToggleSource} onClose={handleClose} + isStreaming={selectedArtifact.status === "streaming"} + viewMode={viewMode} + onViewModeChange={handleViewModeChange} + previewSize={previewSize} + onPreviewSizeChange={handlePreviewSizeChange} /> {/* 渲染器 */}
diff --git a/src/components/artifact/ArtifactRenderer.tsx b/src/components/artifact/ArtifactRenderer.tsx index 38312f172..8b0a73add 100644 --- a/src/components/artifact/ArtifactRenderer.tsx +++ b/src/components/artifact/ArtifactRenderer.tsx @@ -184,6 +184,12 @@ export interface ArtifactRendererComponentProps extends ArtifactRendererProps { className?: string; /** 防抖延迟(毫秒),默认 100ms */ debounceDelay?: number; + /** 是否隐藏内部工具栏 */ + hideToolbar?: boolean; + /** 视图模式(用于代码预览) */ + viewMode?: "source" | "preview"; + /** 预览尺寸 */ + previewSize?: "mobile" | "tablet" | "desktop"; } /** @@ -213,6 +219,9 @@ export const ArtifactRenderer: React.FC = memo( onContentChange, className, debounceDelay = 100, + hideToolbar = false, + viewMode = "source", + previewSize = "desktop", }) => { // 错误状态管理 const [renderError, setRenderError] = useState(null); @@ -352,6 +361,9 @@ export const ArtifactRenderer: React.FC = memo( artifact={debouncedArtifact} isStreaming={isStreaming} onContentChange={onContentChange} + hideToolbar={hideToolbar} + viewMode={viewMode} + previewSize={previewSize} /> diff --git a/src/components/artifact/ArtifactToolbar.tsx b/src/components/artifact/ArtifactToolbar.tsx index b34c931ad..f5befb3be 100644 --- a/src/components/artifact/ArtifactToolbar.tsx +++ b/src/components/artifact/ArtifactToolbar.tsx @@ -14,11 +14,23 @@ import { Eye, ExternalLink, X, + Smartphone, + Tablet, + Monitor, } from "lucide-react"; import { cn } from "@/lib/utils"; import { artifactRegistry } from "@/lib/artifact/registry"; import type { Artifact } from "@/lib/artifact/types"; +/** 视图模式类型 */ +type ViewMode = "source" | "preview"; + +/** 预览尺寸类型 */ +type PreviewSize = "mobile" | "tablet" | "desktop"; + +/** 支持预览的语言列表 */ +const PREVIEWABLE_LANGUAGES = ["html", "svg"]; + /** * 工具栏按钮组件 Props */ @@ -52,6 +64,61 @@ const ToolbarButton: React.FC = memo( ); ToolbarButton.displayName = "ToolbarButton"; +/** + * 预览尺寸选择器组件 + */ +interface SizeSelectorProps { + value: PreviewSize; + onChange: (value: PreviewSize) => void; +} + +const SizeSelector: React.FC = memo( + ({ value, onChange }) => ( +
+ + + +
+ ), +); +SizeSelector.displayName = "SizeSelector"; + /** * ArtifactToolbar Props */ @@ -64,6 +131,16 @@ export interface ArtifactToolbarProps { onToggleSource?: () => void; /** 关闭回调 */ onClose?: () => void; + /** 是否正在流式生成 */ + isStreaming?: boolean; + /** 当前视图模式(用于代码预览) */ + viewMode?: ViewMode; + /** 视图模式变更回调 */ + onViewModeChange?: (mode: ViewMode) => void; + /** 当前预览尺寸 */ + previewSize?: PreviewSize; + /** 预览尺寸变更回调 */ + onPreviewSizeChange?: (size: PreviewSize) => void; } /** @@ -188,19 +265,40 @@ function sanitizeFilename(name: string): string { * - 在新窗口中打开 (Requirement 13.4) * - 关闭按钮 (Requirement 13.5) * - 紧凑的水平布局 (Requirement 13.6) + * - 代码行数显示和流式状态指示 * * @param artifact - 要操作的 Artifact 对象 * @param showSource - 当前是否显示源码视图 * @param onToggleSource - 源码切换回调 * @param onClose - 关闭回调 + * @param isStreaming - 是否正在流式生成 + * @param viewMode - 当前视图模式 + * @param onViewModeChange - 视图模式变更回调 + * @param previewSize - 当前预览尺寸 + * @param onPreviewSizeChange - 预览尺寸变更回调 */ export const ArtifactToolbar: React.FC = memo( - ({ artifact, showSource = false, onToggleSource, onClose }) => { + ({ + artifact, + showSource = false, + onToggleSource, + onClose, + isStreaming: _isStreaming = false, + viewMode = "source", + onViewModeChange, + previewSize = "desktop", + onPreviewSizeChange, + }) => { const [copied, setCopied] = useState(false); // 获取渲染器信息 const entry = artifactRegistry.get(artifact.type); + // 判断是否是代码类型且支持预览 + const isCode = artifact.type === "code"; + const language = artifact.meta.language?.toLowerCase() || ""; + const canPreview = isCode && PREVIEWABLE_LANGUAGES.includes(language); + /** * 复制内容到剪贴板 * @requirements 13.1 @@ -233,12 +331,9 @@ export const ArtifactToolbar: React.FC = memo( const handleOpenInWindow = useCallback(() => { const win = window.open("", "_blank"); if (win) { - // 根据类型决定如何显示内容 if (artifact.type === "html") { - // HTML 直接渲染 win.document.write(artifact.content); } else if (artifact.type === "svg") { - // SVG 直接渲染 win.document.write(` @@ -252,8 +347,10 @@ export const ArtifactToolbar: React.FC = memo( ${artifact.content} `); + } else if (isCode && canPreview) { + // 代码类型的 HTML/SVG 预览 + win.document.write(artifact.content); } else { - // 其他类型显示为预格式化文本 win.document.write(` @@ -270,7 +367,7 @@ export const ArtifactToolbar: React.FC = memo( } win.document.close(); } - }, [artifact]); + }, [artifact, isCode, canPreview]); /** * 切换源码视图 @@ -301,6 +398,10 @@ export const ArtifactToolbar: React.FC = memo( {entry.displayName} )} + {/* 语言标签(代码类型) */} + {isCode && language && ( + {language} + )} {/* 标题 */} {artifact.title} @@ -309,6 +410,43 @@ export const ArtifactToolbar: React.FC = memo( {/* 操作按钮区域 */}
+ {/* 代码预览切换(仅 HTML/SVG 代码) */} + {canPreview && onViewModeChange && ( +
+ + +
+ )} + + {/* 预览尺寸选择器 */} + {canPreview && viewMode === "preview" && onPreviewSizeChange && ( + + )} + {/* 复制按钮 */} = memo( - {/* 源码切换按钮 */} + {/* 源码切换按钮(非代码类型) */} {supportsSourceToggle && ( = { + mobile: 375, + tablet: 768, + desktop: "100%", +}; + +/** 支持预览的语言列表 */ +const PREVIEWABLE_LANGUAGES = ["html", "svg"]; + /** * 语言名称映射表 - * 将常见的语言别名映射到 Prism 支持的语言名称 */ const LANGUAGE_ALIASES: Record = { js: "javascript", @@ -37,8 +81,6 @@ const LANGUAGE_ALIASES: Record = { /** * 规范化语言名称 - * @param language - 原始语言名称 - * @returns 规范化后的语言名称 */ function normalizeLanguage(language: string | undefined): string { if (!language) return "text"; @@ -47,16 +89,46 @@ function normalizeLanguage(language: string | undefined): string { } /** - * 流式指示器组件 - * 显示在代码块底部,表示内容正在流式生成中 + * 检查 refractor 是否支持该语言 */ -const StreamingIndicator: React.FC = memo(() => ( -
- - 生成中... -
-)); -StreamingIndicator.displayName = "StreamingIndicator"; +function isLanguageSupported(lang: string): boolean { + try { + return refractor.registered(lang); + } catch { + return false; + } +} + +/** + * 高亮单行代码 + * @param line - 代码行内容 + * @param language - 语言 + * @returns 高亮后的 HTML 字符串 + */ +function highlightLine(line: string, language: string): string { + if (!line || language === "text" || !isLanguageSupported(language)) { + return escapeHtml(line || " "); + } + + try { + const tree = refractor.highlight(line, language); + return toHtml(tree); + } catch { + return escapeHtml(line); + } +} + +/** + * HTML 转义 + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} /** * 复制按钮组件 @@ -93,7 +165,6 @@ CopyButton.displayName = "CopyButton"; /** * 错误显示组件 - * Requirement 14.4 */ interface ErrorDisplayProps { message: string; @@ -124,132 +195,458 @@ const ErrorDisplay: React.FC = memo( ErrorDisplay.displayName = "ErrorDisplay"; /** - * 代码渲染器组件 + * 视图模式切换按钮组件 + */ +interface ViewModeToggleProps { + value: ViewMode; + onChange: (value: ViewMode) => void; +} + +const ViewModeToggle: React.FC = memo( + ({ value, onChange }) => ( +
+ + +
+ ), +); +ViewModeToggle.displayName = "ViewModeToggle"; + +/** + * 预览尺寸选择器组件 + */ +interface SizeSelectorProps { + value: PreviewSize; + onChange: (value: PreviewSize) => void; +} + +const SizeSelector: React.FC = memo( + ({ value, onChange }) => ( +
+ + + +
+ ), +); +SizeSelector.displayName = "SizeSelector"; + +/** + * HTML 预览组件 + */ +interface HtmlPreviewProps { + content: string; + size: PreviewSize; + onRefresh: () => void; +} + +const HtmlPreview: React.FC = memo( + ({ content, size, onRefresh }) => { + const iframeRef = useRef(null); + const width = PREVIEW_WIDTHS[size]; + + return ( +
+
+
+ +