mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.52.0
## Features ### Artifact 系统优化 - 修复代码块点击后右侧面板无法显示代码的问题 - 移除重复的「生成中...」状态指示器(保留右下角) - 优化代码块折叠卡片的点击交互,自动匹配对应 Artifact ### 会话历史持久化 - 新增 Aster SessionStore 实现,将会话数据存储到 ProxyCast 数据库 - 修复切换话题时无法加载历史消息的问题 - 统一 Aster 框架与 ProxyCast 的会话存储 ### AI 自动生成标题 - 新增 AI 自动生成会话标题功能 - 使用本地代理服务器调用 AI 生成简洁标题 - 失败时自动回退到简单截断方式 ### 流式状态优化 - 代码渲染器工具栏在流式生成时显示旋转加载图标 - Artifact 占位符卡片支持流式状态显示 - 统一流式状态的传递和显示逻辑 ## Bug Fixes - 修复 db.lock() 调用缺少错误处理的编译错误 - 修复 ArtifactToolbar 中未使用的变量警告
This commit is contained in:
+3
-1
@@ -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",
|
||||
|
||||
Generated
+15
-15
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
```
|
||||
@@ -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<String>,
|
||||
/// 自定义指令
|
||||
pub custom_instructions: Option<String>,
|
||||
}
|
||||
|
||||
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<Path>) -> Self {
|
||||
self.options.working_dir = Some(dir.as_ref().to_string_lossy().to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加自定义指令
|
||||
pub fn custom_instructions(mut self, instructions: impl Into<String>) -> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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 格式
|
||||
- 代码块使用三个反引号,并标注语言
|
||||
- 保持简洁,避免冗长的解释
|
||||
|
||||
## 专业客观
|
||||
- 优先考虑技术准确性和真实性
|
||||
- 专注于事实和问题解决
|
||||
- 提供直接、客观的技术信息
|
||||
- 避免过度赞美或情感验证
|
||||
|
||||
## 规划时不要估计时间
|
||||
- 提供具体的实现步骤,但不要估计时间
|
||||
- 专注于需要做什么,而不是什么时候做"#;
|
||||
@@ -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<AppStates, String> {
|
||||
) = 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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -402,3 +402,163 @@ pub async fn general_chat_stop_generation(session_id: String) -> Result<bool, St
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动生成会话标题请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GenerateTitleRequest {
|
||||
/// 会话 ID
|
||||
pub session_id: String,
|
||||
/// 用户第一条消息内容
|
||||
pub first_message: String,
|
||||
/// Provider 名称(可选)
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
/// 模型名称(可选)
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// 自动生成会话标题
|
||||
///
|
||||
/// 基于用户第一条消息,调用 AI 生成简短的会话标题
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - 生成标题请求
|
||||
#[tauri::command]
|
||||
pub async fn general_chat_generate_title(
|
||||
db: State<'_, DbConnection>,
|
||||
request: GenerateTitleRequest,
|
||||
) -> Result<String, String> {
|
||||
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::<String>()
|
||||
);
|
||||
|
||||
// 尝试调用 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<String, String> {
|
||||
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::<String>())
|
||||
} 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::<String>())
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Session> {
|
||||
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::<String>, 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<Session> {
|
||||
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<String>>(2)?,
|
||||
row.get::<_, Option<String>>(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<String> = if !tool_requests.is_empty() {
|
||||
Some(serde_json::to_string(&tool_requests)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 从 content 中提取 tool_call_id(ToolResponse 类型)
|
||||
let tool_call_id: Option<String> = 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<String> = if !tool_requests.is_empty() {
|
||||
Some(serde_json::to_string(&tool_requests)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tool_call_id: Option<String> = 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<Vec<Session>> {
|
||||
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<Session> = stmt
|
||||
.query_map([], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let model: String = row.get(1)?;
|
||||
let title: Option<String> = 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<Vec<Session>> {
|
||||
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<SessionInsights> {
|
||||
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<String> {
|
||||
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<Session> {
|
||||
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<Session> {
|
||||
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<String>,
|
||||
_model_config: Option<ModelConfig>,
|
||||
) -> 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<Recipe>,
|
||||
_user_recipe_values: Option<HashMap<String, String>>,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search_chat_history(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: Option<usize>,
|
||||
_after_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
_before_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
_exclude_session_id: Option<String>,
|
||||
) -> Result<Vec<ChatHistoryMatch>> {
|
||||
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<ChatHistoryMatch> = stmt
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let session_id: String = row.get(0)?;
|
||||
let session_name: Option<String> = 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<Conversation> {
|
||||
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<Message> = 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<String> = row.get(3)?;
|
||||
let _tool_call_id: Option<String> = row.get(4)?;
|
||||
|
||||
Ok((role, content_json))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.filter_map(|(role, content_json)| {
|
||||
// 尝试解析消息内容
|
||||
let content: Vec<MessageContent> = 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<usize> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM agent_messages WHERE session_id = ?",
|
||||
[session_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ArtifactPlaceholderProps> = memo(
|
||||
({ language, lineCount, onClick }) => {
|
||||
({ language, lineCount, isStreaming = false, onClick }) => {
|
||||
const displayName = getLanguageDisplayName(language);
|
||||
|
||||
return (
|
||||
@@ -129,7 +143,16 @@ export const ArtifactPlaceholder: React.FC<ArtifactPlaceholderProps> = memo(
|
||||
<ContentWrapper>
|
||||
<Title>{displayName} 代码</Title>
|
||||
<Subtitle>
|
||||
{lineCount ? `${lineCount} 行` : "点击在画布中查看"}
|
||||
{isStreaming ? (
|
||||
<>
|
||||
<SpinningLoader size={12} />
|
||||
<span>生成中...</span>
|
||||
</>
|
||||
) : lineCount ? (
|
||||
`${lineCount} 行`
|
||||
) : (
|
||||
"点击在画布中查看"
|
||||
)}
|
||||
</Subtitle>
|
||||
</ContentWrapper>
|
||||
<ActionIcon>
|
||||
|
||||
@@ -266,10 +266,18 @@ interface MarkdownRendererProps {
|
||||
collapseCodeBlocks?: boolean;
|
||||
/** 代码块点击回调(用于在画布中显示) */
|
||||
onCodeBlockClick?: (language: string, code: string) => void;
|
||||
/** 是否正在流式生成 */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
({ content, onA2UISubmit, collapseCodeBlocks = false, onCodeBlockClick }) => {
|
||||
({
|
||||
content,
|
||||
onA2UISubmit,
|
||||
collapseCodeBlocks = false,
|
||||
onCodeBlockClick,
|
||||
isStreaming = false,
|
||||
}) => {
|
||||
const [copied, setCopied] = React.useState<string | null>(null);
|
||||
|
||||
const handleCopy = (code: string) => {
|
||||
@@ -479,7 +487,8 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
return (
|
||||
<ArtifactPlaceholder
|
||||
language={language}
|
||||
lineCount={lineCount}
|
||||
lineCount={isStreaming ? undefined : lineCount}
|
||||
isStreaming={isStreaming}
|
||||
onClick={() => onCodeBlockClick?.(language, codeContent)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
onA2UISubmit={onA2UISubmit}
|
||||
collapseCodeBlocks={collapseCodeBlocks}
|
||||
onCodeBlockClick={onCodeBlockClick}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -256,6 +257,7 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
onA2UISubmit={onA2UISubmit}
|
||||
collapseCodeBlocks={collapseCodeBlocks}
|
||||
onCodeBlockClick={onCodeBlockClick}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<ArtifactToolbar
|
||||
artifact={currentArtifact}
|
||||
onClose={handleCloseCanvas}
|
||||
isStreaming={currentArtifact.status === "streaming"}
|
||||
viewMode={artifactViewMode}
|
||||
onViewModeChange={setArtifactViewMode}
|
||||
previewSize={artifactPreviewSize}
|
||||
onPreviewSizeChange={setArtifactPreviewSize}
|
||||
/>
|
||||
{/* 渲染区域 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ArtifactRenderer
|
||||
artifact={currentArtifact}
|
||||
isStreaming={currentArtifact.status === "streaming"}
|
||||
hideToolbar={true}
|
||||
viewMode={artifactViewMode}
|
||||
previewSize={artifactPreviewSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1189,6 +1226,8 @@ export function AgentChatPage({
|
||||
mappedTheme,
|
||||
handleCloseCanvas,
|
||||
isSending,
|
||||
artifactViewMode,
|
||||
artifactPreviewSize,
|
||||
]);
|
||||
|
||||
// ========== 渲染逻辑 ==========
|
||||
|
||||
@@ -150,6 +150,10 @@ export const ArtifactPanel: React.FC<ArtifactPanelProps> = 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<HTMLDivElement>(null);
|
||||
@@ -264,6 +268,7 @@ export const ArtifactPanel: React.FC<ArtifactPanelProps> = memo(
|
||||
(id: string) => {
|
||||
dispatch({ type: "select", id });
|
||||
setShowSource(false); // 切换 artifact 时重置源码视图
|
||||
setViewMode("source"); // 重置视图模式
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
@@ -275,6 +280,23 @@ export const ArtifactPanel: React.FC<ArtifactPanelProps> = 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<ArtifactPanelProps> = memo(
|
||||
showSource={showSource}
|
||||
onToggleSource={handleToggleSource}
|
||||
onClose={handleClose}
|
||||
isStreaming={selectedArtifact.status === "streaming"}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
previewSize={previewSize}
|
||||
onPreviewSizeChange={handlePreviewSizeChange}
|
||||
/>
|
||||
{/* 渲染器 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ArtifactRenderer
|
||||
artifact={selectedArtifact}
|
||||
isStreaming={selectedArtifact.status === "streaming"}
|
||||
hideToolbar={true}
|
||||
viewMode={viewMode}
|
||||
previewSize={previewSize}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<ArtifactRendererComponentProps> = memo(
|
||||
onContentChange,
|
||||
className,
|
||||
debounceDelay = 100,
|
||||
hideToolbar = false,
|
||||
viewMode = "source",
|
||||
previewSize = "desktop",
|
||||
}) => {
|
||||
// 错误状态管理
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
@@ -352,6 +361,9 @@ export const ArtifactRenderer: React.FC<ArtifactRendererComponentProps> = memo(
|
||||
artifact={debouncedArtifact}
|
||||
isStreaming={isStreaming}
|
||||
onContentChange={onContentChange}
|
||||
hideToolbar={hideToolbar}
|
||||
viewMode={viewMode}
|
||||
previewSize={previewSize}
|
||||
/>
|
||||
</ArtifactErrorBoundary>
|
||||
</Suspense>
|
||||
|
||||
@@ -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<ToolbarButtonProps> = memo(
|
||||
);
|
||||
ToolbarButton.displayName = "ToolbarButton";
|
||||
|
||||
/**
|
||||
* 预览尺寸选择器组件
|
||||
*/
|
||||
interface SizeSelectorProps {
|
||||
value: PreviewSize;
|
||||
onChange: (value: PreviewSize) => void;
|
||||
}
|
||||
|
||||
const SizeSelector: React.FC<SizeSelectorProps> = memo(
|
||||
({ value, onChange }) => (
|
||||
<div className="inline-flex items-center rounded bg-white/5 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("mobile")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all",
|
||||
value === "mobile"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-white",
|
||||
)}
|
||||
title="手机"
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("tablet")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all",
|
||||
value === "tablet"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-white",
|
||||
)}
|
||||
title="平板"
|
||||
>
|
||||
<Tablet className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("desktop")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all",
|
||||
value === "desktop"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-white",
|
||||
)}
|
||||
title="桌面"
|
||||
>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
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<ArtifactToolbarProps> = 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<ArtifactToolbarProps> = 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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -252,8 +347,10 @@ export const ArtifactToolbar: React.FC<ArtifactToolbarProps> = memo(
|
||||
<body>${artifact.content}</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (isCode && canPreview) {
|
||||
// 代码类型的 HTML/SVG 预览
|
||||
win.document.write(artifact.content);
|
||||
} else {
|
||||
// 其他类型显示为预格式化文本
|
||||
win.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -270,7 +367,7 @@ export const ArtifactToolbar: React.FC<ArtifactToolbarProps> = memo(
|
||||
}
|
||||
win.document.close();
|
||||
}
|
||||
}, [artifact]);
|
||||
}, [artifact, isCode, canPreview]);
|
||||
|
||||
/**
|
||||
* 切换源码视图
|
||||
@@ -301,6 +398,10 @@ export const ArtifactToolbar: React.FC<ArtifactToolbarProps> = memo(
|
||||
{entry.displayName}
|
||||
</span>
|
||||
)}
|
||||
{/* 语言标签(代码类型) */}
|
||||
{isCode && language && (
|
||||
<span className="text-xs text-gray-500 font-mono">{language}</span>
|
||||
)}
|
||||
{/* 标题 */}
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{artifact.title}
|
||||
@@ -309,6 +410,43 @@ export const ArtifactToolbar: React.FC<ArtifactToolbarProps> = memo(
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{/* 代码预览切换(仅 HTML/SVG 代码) */}
|
||||
{canPreview && onViewModeChange && (
|
||||
<div className="inline-flex items-center rounded bg-white/5 p-0.5 mr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewModeChange("source")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded text-xs transition-all",
|
||||
viewMode === "source"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white",
|
||||
)}
|
||||
title="源码"
|
||||
>
|
||||
<Code className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewModeChange("preview")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded text-xs transition-all",
|
||||
viewMode === "preview"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white",
|
||||
)}
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览尺寸选择器 */}
|
||||
{canPreview && viewMode === "preview" && onPreviewSizeChange && (
|
||||
<SizeSelector value={previewSize} onChange={onPreviewSizeChange} />
|
||||
)}
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<ToolbarButton
|
||||
onClick={handleCopy}
|
||||
@@ -326,7 +464,7 @@ export const ArtifactToolbar: React.FC<ArtifactToolbarProps> = memo(
|
||||
<Download className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* 源码切换按钮 */}
|
||||
{/* 源码切换按钮(非代码类型) */}
|
||||
{supportsSourceToggle && (
|
||||
<ToolbarButton
|
||||
onClick={handleToggleSource}
|
||||
|
||||
@@ -1,20 +1,64 @@
|
||||
/**
|
||||
* @file 代码渲染器组件
|
||||
* @description Artifact 系统的代码渲染器,支持语法高亮、行号显示、复制功能和流式内容更新
|
||||
* @file 代码渲染器组件(虚拟化版本)
|
||||
* @description Artifact 系统的代码渲染器,使用 @tanstack/react-virtual 实现虚拟滚动
|
||||
* 解决长代码渲染时的性能问题(花屏、卡顿)
|
||||
* 支持 HTML 代码的预览/源码切换
|
||||
* @module components/artifact/renderers/CodeRenderer
|
||||
* @requirements 4.1, 4.2, 4.3, 4.4, 4.6, 14.4
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, memo } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Copy, Check, Loader2, AlertCircle } from "lucide-react";
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
memo,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { refractor } from "refractor";
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
Code2,
|
||||
RefreshCw,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import "./code-highlight.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ArtifactRendererProps } from "@/lib/artifact/types";
|
||||
|
||||
/** 行高常量 */
|
||||
const LINE_HEIGHT = 20;
|
||||
/** 行号宽度 */
|
||||
const LINE_NUMBER_WIDTH = 48;
|
||||
/** 虚拟化阈值:超过此行数启用虚拟滚动 */
|
||||
const VIRTUALIZATION_THRESHOLD = 100;
|
||||
|
||||
/** 视图模式类型 */
|
||||
type ViewMode = "source" | "preview";
|
||||
|
||||
/** 预览尺寸类型 */
|
||||
type PreviewSize = "mobile" | "tablet" | "desktop";
|
||||
|
||||
/** 预览尺寸配置 */
|
||||
const PREVIEW_WIDTHS: Record<PreviewSize, number | string> = {
|
||||
mobile: 375,
|
||||
tablet: 768,
|
||||
desktop: "100%",
|
||||
};
|
||||
|
||||
/** 支持预览的语言列表 */
|
||||
const PREVIEWABLE_LANGUAGES = ["html", "svg"];
|
||||
|
||||
/**
|
||||
* 语言名称映射表
|
||||
* 将常见的语言别名映射到 Prism 支持的语言名称
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
js: "javascript",
|
||||
@@ -37,8 +81,6 @@ const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* 规范化语言名称
|
||||
* @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(() => (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded bg-blue-500/20 text-blue-400 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>生成中...</span>
|
||||
</div>
|
||||
));
|
||||
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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制按钮组件
|
||||
@@ -93,7 +165,6 @@ CopyButton.displayName = "CopyButton";
|
||||
|
||||
/**
|
||||
* 错误显示组件
|
||||
* Requirement 14.4
|
||||
*/
|
||||
interface ErrorDisplayProps {
|
||||
message: string;
|
||||
@@ -124,132 +195,458 @@ const ErrorDisplay: React.FC<ErrorDisplayProps> = memo(
|
||||
ErrorDisplay.displayName = "ErrorDisplay";
|
||||
|
||||
/**
|
||||
* 代码渲染器组件
|
||||
* 视图模式切换按钮组件
|
||||
*/
|
||||
interface ViewModeToggleProps {
|
||||
value: ViewMode;
|
||||
onChange: (value: ViewMode) => void;
|
||||
}
|
||||
|
||||
const ViewModeToggle: React.FC<ViewModeToggleProps> = memo(
|
||||
({ value, onChange }) => (
|
||||
<div className="inline-flex items-center rounded bg-white/5 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("source")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded text-xs transition-all",
|
||||
value === "source"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white",
|
||||
)}
|
||||
title="源码"
|
||||
>
|
||||
<Code2 className="w-3 h-3" />
|
||||
<span>源码</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("preview")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded text-xs transition-all",
|
||||
value === "preview"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white",
|
||||
)}
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>预览</span>
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
ViewModeToggle.displayName = "ViewModeToggle";
|
||||
|
||||
/**
|
||||
* 预览尺寸选择器组件
|
||||
*/
|
||||
interface SizeSelectorProps {
|
||||
value: PreviewSize;
|
||||
onChange: (value: PreviewSize) => void;
|
||||
}
|
||||
|
||||
const SizeSelector: React.FC<SizeSelectorProps> = memo(
|
||||
({ value, onChange }) => (
|
||||
<div className="inline-flex items-center rounded bg-white/5 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("mobile")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all",
|
||||
value === "mobile"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-white",
|
||||
)}
|
||||
title="手机"
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("tablet")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all",
|
||||
value === "tablet"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-white",
|
||||
)}
|
||||
title="平板"
|
||||
>
|
||||
<Tablet className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("desktop")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all",
|
||||
value === "desktop"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-white",
|
||||
)}
|
||||
title="桌面"
|
||||
>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
SizeSelector.displayName = "SizeSelector";
|
||||
|
||||
/**
|
||||
* HTML 预览组件
|
||||
*/
|
||||
interface HtmlPreviewProps {
|
||||
content: string;
|
||||
size: PreviewSize;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const HtmlPreview: React.FC<HtmlPreviewProps> = memo(
|
||||
({ content, size, onRefresh }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const width = PREVIEW_WIDTHS[size];
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto bg-gray-100 p-4">
|
||||
<div className="h-full flex items-start justify-center">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="absolute -top-8 right-0 p-1 text-gray-500 hover:text-gray-700 rounded hover:bg-gray-200"
|
||||
title="刷新预览"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={content}
|
||||
sandbox="allow-scripts"
|
||||
className={cn(
|
||||
"w-full h-full min-h-[400px] bg-white border-0 shadow-sm",
|
||||
size !== "desktop" && "rounded-lg border border-gray-300",
|
||||
)}
|
||||
title="HTML 预览"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
HtmlPreview.displayName = "HtmlPreview";
|
||||
|
||||
/**
|
||||
* 单行代码组件(用于虚拟列表)
|
||||
*/
|
||||
interface CodeLineProps {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
showLineNumbers: boolean;
|
||||
}
|
||||
|
||||
const CodeLine: React.FC<CodeLineProps> = memo(
|
||||
({ lineNumber, content, showLineNumbers }) => (
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
height: LINE_HEIGHT,
|
||||
lineHeight: `${LINE_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<span
|
||||
className="flex-shrink-0 text-right pr-4 select-none text-[#636d83]"
|
||||
style={{
|
||||
width: LINE_NUMBER_WIDTH,
|
||||
fontSize: "13px",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex-1 whitespace-pre"
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: content || " " }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
CodeLine.displayName = "CodeLine";
|
||||
|
||||
/**
|
||||
* 虚拟化代码视图组件
|
||||
*/
|
||||
interface VirtualizedCodeViewProps {
|
||||
lines: string[];
|
||||
highlightedLines: string[];
|
||||
showLineNumbers: boolean;
|
||||
}
|
||||
|
||||
const VirtualizedCodeView: React.FC<VirtualizedCodeViewProps> = memo(
|
||||
({ lines, highlightedLines, showLineNumbers }) => {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: lines.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => LINE_HEIGHT,
|
||||
overscan: 10, // 预渲染上下各 10 行,减少滚动时的空白
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="flex-1 overflow-auto p-3"
|
||||
style={{ contain: "strict" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<CodeLine
|
||||
lineNumber={virtualRow.index + 1}
|
||||
content={highlightedLines[virtualRow.index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
VirtualizedCodeView.displayName = "VirtualizedCodeView";
|
||||
|
||||
/**
|
||||
* 普通代码视图组件(行数较少时使用)
|
||||
*/
|
||||
interface SimpleCodeViewProps {
|
||||
highlightedLines: string[];
|
||||
showLineNumbers: boolean;
|
||||
}
|
||||
|
||||
const SimpleCodeView: React.FC<SimpleCodeViewProps> = memo(
|
||||
({ highlightedLines, showLineNumbers }) => (
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{highlightedLines.map((content, index) => (
|
||||
<CodeLine
|
||||
key={index}
|
||||
lineNumber={index + 1}
|
||||
content={content}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
SimpleCodeView.displayName = "SimpleCodeView";
|
||||
|
||||
/**
|
||||
* 代码渲染器组件(虚拟化版本)
|
||||
*
|
||||
* 功能特性:
|
||||
* - 使用 react-syntax-highlighter 实现语法高亮 (Requirement 4.1)
|
||||
* - 使用 refractor 实现语法高亮 (Requirement 4.1)
|
||||
* - 显示行号 (Requirement 4.2)
|
||||
* - 提供复制到剪贴板功能 (Requirement 4.3)
|
||||
* - 支持从 artifact 元数据检测语言 (Requirement 4.4)
|
||||
* - 支持流式内容更新,无闪烁 (Requirement 4.6)
|
||||
*
|
||||
* @param artifact - 要渲染的 Artifact 对象
|
||||
* @param isStreaming - 是否处于流式生成状态
|
||||
* - 使用 @tanstack/react-virtual 实现虚拟滚动,解决长代码性能问题
|
||||
* - HTML/SVG 代码支持预览/源码切换
|
||||
*/
|
||||
export const CodeRenderer: React.FC<ArtifactRendererProps> = memo(
|
||||
({ artifact, isStreaming = false }) => {
|
||||
({
|
||||
artifact,
|
||||
isStreaming = false,
|
||||
hideToolbar = false,
|
||||
viewMode: externalViewMode,
|
||||
previewSize: externalPreviewSize,
|
||||
onViewModeChange,
|
||||
onPreviewSizeChange,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 内部状态(当没有外部控制时使用)
|
||||
const [internalViewMode, setInternalViewMode] =
|
||||
useState<ViewMode>("source");
|
||||
const [internalPreviewSize, setInternalPreviewSize] =
|
||||
useState<PreviewSize>("desktop");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// 从 artifact 元数据获取语言,并规范化
|
||||
// 使用外部状态或内部状态
|
||||
const viewMode = externalViewMode ?? internalViewMode;
|
||||
const previewSize = externalPreviewSize ?? internalPreviewSize;
|
||||
|
||||
// 处理视图模式变更
|
||||
const handleViewModeChange = useCallback(
|
||||
(mode: ViewMode) => {
|
||||
if (onViewModeChange) {
|
||||
onViewModeChange(mode);
|
||||
} else {
|
||||
setInternalViewMode(mode);
|
||||
}
|
||||
},
|
||||
[onViewModeChange],
|
||||
);
|
||||
|
||||
// 处理预览尺寸变更
|
||||
const handlePreviewSizeChange = useCallback(
|
||||
(size: PreviewSize) => {
|
||||
if (onPreviewSizeChange) {
|
||||
onPreviewSizeChange(size);
|
||||
} else {
|
||||
setInternalPreviewSize(size);
|
||||
}
|
||||
},
|
||||
[onPreviewSizeChange],
|
||||
);
|
||||
|
||||
// 规范化语言
|
||||
const language = useMemo(() => {
|
||||
try {
|
||||
return normalizeLanguage(artifact.meta.language);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "语言检测失败";
|
||||
console.error(
|
||||
"[CodeRenderer] Error normalizing language:",
|
||||
errorMessage,
|
||||
err,
|
||||
);
|
||||
console.error("[CodeRenderer] Error normalizing language:", err);
|
||||
return "text";
|
||||
}
|
||||
}, [artifact.meta.language]);
|
||||
|
||||
// 复制代码到剪贴板
|
||||
// 是否支持预览
|
||||
const canPreview = PREVIEWABLE_LANGUAGES.includes(language);
|
||||
|
||||
// 分割代码为行
|
||||
const lines = useMemo(() => {
|
||||
if (!artifact.content) return [""];
|
||||
return artifact.content.split("\n");
|
||||
}, [artifact.content]);
|
||||
|
||||
// 是否显示行号
|
||||
const showLineNumbers = lines.length > 1;
|
||||
|
||||
// 是否使用虚拟化
|
||||
const useVirtualization = lines.length > VIRTUALIZATION_THRESHOLD;
|
||||
|
||||
// 高亮所有行(带缓存)
|
||||
const highlightedLines = useMemo(() => {
|
||||
return lines.map((line) => highlightLine(line, language));
|
||||
}, [lines, language]);
|
||||
|
||||
// 复制代码
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(artifact.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "复制失败";
|
||||
console.error(
|
||||
"[CodeRenderer] Error copying to clipboard:",
|
||||
errorMessage,
|
||||
err,
|
||||
);
|
||||
console.error("[CodeRenderer] Error copying:", err);
|
||||
}
|
||||
}, [artifact.content]);
|
||||
|
||||
// 计算是否显示行号(超过 1 行时显示)
|
||||
const showLineNumbers = useMemo(() => {
|
||||
try {
|
||||
return artifact.content.split("\n").length > 1;
|
||||
} catch (err) {
|
||||
console.error("[CodeRenderer] Error calculating line numbers:", err);
|
||||
return false;
|
||||
}
|
||||
}, [artifact.content]);
|
||||
// 刷新预览
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// 验证内容是否可渲染
|
||||
useMemo(() => {
|
||||
try {
|
||||
if (artifact.content === null || artifact.content === undefined) {
|
||||
throw new Error("代码内容为空");
|
||||
}
|
||||
// 验证内容
|
||||
useEffect(() => {
|
||||
if (artifact.content === null || artifact.content === undefined) {
|
||||
setError("代码内容为空");
|
||||
} else {
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "内容验证失败";
|
||||
console.error(
|
||||
"[CodeRenderer] Error validating content:",
|
||||
errorMessage,
|
||||
err,
|
||||
);
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [artifact.content]);
|
||||
|
||||
// 如果有错误,显示错误界面
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} content={artifact.content || ""} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col bg-[#282c34] rounded-lg overflow-hidden">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[#21252b] border-b border-white/10">
|
||||
<span className="text-xs text-gray-400 font-mono">{language}</span>
|
||||
<CopyButton copied={copied} onClick={handleCopy} />
|
||||
</div>
|
||||
<div className="code-renderer relative h-full flex flex-col bg-[#282c34] rounded-lg overflow-hidden">
|
||||
{/* 工具栏(当外部有工具栏时隐藏) */}
|
||||
{!hideToolbar && (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[#21252b] border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
{language}
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-blue-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>生成中...</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">{lines.length} 行</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 预览/源码切换(仅 HTML/SVG) */}
|
||||
{canPreview && (
|
||||
<ViewModeToggle
|
||||
value={viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
/>
|
||||
)}
|
||||
{/* 预览尺寸选择器 */}
|
||||
{canPreview && viewMode === "preview" && (
|
||||
<SizeSelector
|
||||
value={previewSize}
|
||||
onChange={handlePreviewSizeChange}
|
||||
/>
|
||||
)}
|
||||
<CopyButton copied={copied} onClick={handleCopy} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 代码内容区域 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
{/* 内容区域 */}
|
||||
{viewMode === "preview" && canPreview ? (
|
||||
<HtmlPreview
|
||||
key={refreshKey}
|
||||
content={artifact.content}
|
||||
size={previewSize}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
) : useVirtualization ? (
|
||||
<VirtualizedCodeView
|
||||
lines={lines}
|
||||
highlightedLines={highlightedLines}
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "12px",
|
||||
background: "transparent",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.5em",
|
||||
paddingRight: "1em",
|
||||
color: "#636d83",
|
||||
userSelect: "none",
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{artifact.content || " "}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
||||
{/* 流式指示器 */}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
/>
|
||||
) : (
|
||||
<SimpleCodeView
|
||||
highlightedLines={highlightedLines}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file 代码高亮样式
|
||||
* @description 基于 One Dark 主题的语法高亮 CSS
|
||||
*/
|
||||
|
||||
/* 基础代码样式 */
|
||||
.code-renderer {
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
/* 关键字 */
|
||||
.code-renderer .token.keyword,
|
||||
.code-renderer .keyword {
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
/* 字符串 */
|
||||
.code-renderer .token.string,
|
||||
.code-renderer .string {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
/* 数字 */
|
||||
.code-renderer .token.number,
|
||||
.code-renderer .number {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
/* 注释 */
|
||||
.code-renderer .token.comment,
|
||||
.code-renderer .comment {
|
||||
color: #5c6370;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 函数名 */
|
||||
.code-renderer .token.function,
|
||||
.code-renderer .function {
|
||||
color: #61afef;
|
||||
}
|
||||
|
||||
/* 类名 */
|
||||
.code-renderer .token.class-name,
|
||||
.code-renderer .class-name {
|
||||
color: #e5c07b;
|
||||
}
|
||||
|
||||
/* 操作符 */
|
||||
.code-renderer .token.operator,
|
||||
.code-renderer .operator {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
/* 标点符号 */
|
||||
.code-renderer .token.punctuation,
|
||||
.code-renderer .punctuation {
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
/* 属性 */
|
||||
.code-renderer .token.property,
|
||||
.code-renderer .property {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
/* 变量 */
|
||||
.code-renderer .token.variable,
|
||||
.code-renderer .variable {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
/* 布尔值 */
|
||||
.code-renderer .token.boolean,
|
||||
.code-renderer .boolean {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
/* 正则表达式 */
|
||||
.code-renderer .token.regex,
|
||||
.code-renderer .regex {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
/* 标签 (HTML/JSX) */
|
||||
.code-renderer .token.tag,
|
||||
.code-renderer .tag {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
/* 属性名 (HTML/JSX) */
|
||||
.code-renderer .token.attr-name,
|
||||
.code-renderer .attr-name {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
/* 属性值 (HTML/JSX) */
|
||||
.code-renderer .token.attr-value,
|
||||
.code-renderer .attr-value {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
/* 内置函数 */
|
||||
.code-renderer .token.builtin,
|
||||
.code-renderer .builtin {
|
||||
color: #e5c07b;
|
||||
}
|
||||
|
||||
/* 常量 */
|
||||
.code-renderer .token.constant,
|
||||
.code-renderer .constant {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
/* 命名空间 */
|
||||
.code-renderer .token.namespace,
|
||||
.code-renderer .namespace {
|
||||
color: #e5c07b;
|
||||
}
|
||||
|
||||
/* 选择器 (CSS) */
|
||||
.code-renderer .token.selector,
|
||||
.code-renderer .selector {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
/* 重要 (CSS) */
|
||||
.code-renderer .token.important,
|
||||
.code-renderer .important {
|
||||
color: #c678dd;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 模板字符串 */
|
||||
.code-renderer .token.template-string,
|
||||
.code-renderer .template-string {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
/* 插值 */
|
||||
.code-renderer .token.interpolation,
|
||||
.code-renderer .interpolation {
|
||||
color: #e06c75;
|
||||
}
|
||||
@@ -175,16 +175,29 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
|
||||
/**
|
||||
* 自动生成会话标题
|
||||
* 基于第一条用户消息生成
|
||||
* 基于第一条用户消息,调用 AI 生成简短标题
|
||||
*/
|
||||
const generateTitle = useCallback(
|
||||
async (sessionId: string, firstMessage: string) => {
|
||||
// 简单实现:截取前 20 个字符作为标题
|
||||
const title =
|
||||
firstMessage.slice(0, 20) + (firstMessage.length > 20 ? "..." : "");
|
||||
await renameSession(sessionId, title);
|
||||
try {
|
||||
// 调用后端命令生成标题
|
||||
const title = await invoke<string>("general_chat_generate_title", {
|
||||
request: {
|
||||
session_id: sessionId,
|
||||
first_message: firstMessage,
|
||||
},
|
||||
});
|
||||
// 更新本地状态
|
||||
updateSession(sessionId, { name: title });
|
||||
} catch (error) {
|
||||
console.error("生成标题失败:", error);
|
||||
// 失败时使用简单截取作为 fallback
|
||||
const fallbackTitle =
|
||||
firstMessage.slice(0, 20) + (firstMessage.length > 20 ? "..." : "");
|
||||
await renameSession(sessionId, fallbackTitle);
|
||||
}
|
||||
},
|
||||
[renameSession],
|
||||
[renameSession, updateSession],
|
||||
);
|
||||
|
||||
// 自动加载会话列表
|
||||
|
||||
@@ -623,6 +623,7 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
streaming,
|
||||
currentSessionId,
|
||||
messages,
|
||||
sessions,
|
||||
workflowManagers,
|
||||
workflowEnabled,
|
||||
} = get();
|
||||
@@ -651,6 +652,38 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
streaming: { ...DEFAULT_STREAMING_STATE },
|
||||
}));
|
||||
|
||||
// 自动生成会话标题:当这是第一轮对话完成时(2条消息:用户+助手)
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
if (updatedMessages.length === 2 && currentSession?.name === "新对话") {
|
||||
// 获取第一条用户消息
|
||||
const firstUserMessage = updatedMessages.find(
|
||||
(m) => m.role === "user",
|
||||
);
|
||||
if (firstUserMessage) {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const title = await invoke<string>(
|
||||
"general_chat_generate_title",
|
||||
{
|
||||
request: {
|
||||
session_id: currentSessionId,
|
||||
first_message: firstUserMessage.content,
|
||||
},
|
||||
},
|
||||
);
|
||||
// 更新本地会话标题
|
||||
get().updateSession(currentSessionId, { name: title });
|
||||
} catch (error) {
|
||||
console.warn("自动生成标题失败:", error);
|
||||
// 失败时使用简单截取
|
||||
const fallbackTitle =
|
||||
firstUserMessage.content.slice(0, 20) +
|
||||
(firstUserMessage.content.length > 20 ? "..." : "");
|
||||
get().updateSession(currentSessionId, { name: fallbackTitle });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了工作流,执行 Post-Action 阶段
|
||||
const workflowManager = workflowManagers[currentSessionId];
|
||||
if (workflowManager && workflowEnabled) {
|
||||
|
||||
@@ -100,6 +100,16 @@ export interface ArtifactRendererProps {
|
||||
isStreaming?: boolean;
|
||||
/** 内容变更回调(用于可编辑的渲染器) */
|
||||
onContentChange?: (content: string) => void;
|
||||
/** 是否隐藏内部工具栏(当外部已有工具栏时使用) */
|
||||
hideToolbar?: boolean;
|
||||
/** 视图模式(用于代码预览) */
|
||||
viewMode?: "source" | "preview";
|
||||
/** 视图模式变更回调 */
|
||||
onViewModeChange?: (mode: "source" | "preview") => void;
|
||||
/** 预览尺寸 */
|
||||
previewSize?: "mobile" | "tablet" | "desktop";
|
||||
/** 预览尺寸变更回调 */
|
||||
onPreviewSizeChange?: (size: "mobile" | "tablet" | "desktop") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user