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:
coso
2026-02-02 15:38:00 +08:00
parent 2f3194a56e
commit be4c4641c2
25 changed files with 2189 additions and 159 deletions
+3 -1
View File
@@ -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",
+15 -15
View File
@@ -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 -3
View File
@@ -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
+65
View File
@@ -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?;
```
+183
View File
@@ -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"));
}
}
+14
View File
@@ -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::*;
+121
View File
@@ -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 格式
- 代码块使用三个反引号,并标注语言
- 保持简洁,避免冗长的解释
## 专业客观
- 优先考虑技术准确性和真实性
- 专注于事实和问题解决
- 提供直接、客观的技术信息
- 避免过度赞美或情感验证
## 规划时不要估计时间
- 提供具体的实现步骤,但不要估计时间
- 专注于需要做什么,而不是什么时候做"#;
+19
View File
@@ -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();
+1
View File
@@ -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,
+160
View File
@@ -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(&timestamp)
.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
View File
@@ -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 -1
View File
@@ -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}
/>
);
}
+54 -15
View File
@@ -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,
]);
// ========== 渲染逻辑 ==========
+30
View File
@@ -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>
+145 -7
View File
@@ -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}
+502 -105
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 复制按钮组件
@@ -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 || "&nbsp;" }}
/>
</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) {
+10
View File
@@ -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;
}
/**