feat: v0.51.0 - Artifact System 集成与 Content Creator 增强

主要更新:
- 集成 Artifact 系统到聊天界面,支持代码块折叠和画布预览
- 新增 Novel/Script Canvas 类型支持
- 新增 Projects 模块(项目管理、内容编辑、记忆系统)
- 新增 Memory 系统(角色、大纲、风格指南、世界观)
- 新增 Reasoning Handler 支持思维链处理
- 优化 Workspace 管理和 Agent 类型定义
This commit is contained in:
coso
2026-02-02 01:15:54 +08:00
parent ef43d3e74d
commit 2f3194a56e
120 changed files with 23310 additions and 446 deletions
+3 -1
View File
@@ -56,4 +56,6 @@ src-tauri/gen
.task
Taskfile.yml
nul
.proptest-regressions
.proptest-regressions
.codex
openspec
+8 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.50.0",
"version": "0.51.0",
"type": "module",
"repository": {
"type": "git",
@@ -24,6 +24,7 @@
"detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose"
},
"dependencies": {
"@babel/standalone": "^7.29.0",
"@fabianlars/tauri-plugin-oauth": "^2",
"@floating-ui/react": "^0.27.16",
"@observablehq/plot": "^0.6.17",
@@ -46,6 +47,10 @@
"@tauri-apps/plugin-dialog": "2.5.0",
"@tauri-apps/plugin-global-shortcut": "2.3.1",
"@tauri-apps/plugin-shell": "2.3.4",
"@tiptap/extension-placeholder": "^3.18.0",
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"@tonejs/midi": "^2.0.28",
"@types/lodash-es": "^4.17.12",
"@types/styled-components": "^5.1.36",
@@ -64,6 +69,7 @@
"jotai": "^2.16.1",
"lodash-es": "^4.17.22",
"lucide-react": "^0.460.0",
"mermaid": "^11.12.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.1",
@@ -86,6 +92,7 @@
"@eslint/js": "^9.15.0",
"@fast-check/vitest": "^0.2.4",
"@tauri-apps/cli": "^2.0.0",
"@types/babel__standalone": "^7.1.9",
"@types/fabric": "^5.3.11",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
+55 -13
View File
@@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster"
version = "0.4.6"
version = "0.5.1"
dependencies = [
"ahash",
"anyhow",
@@ -218,7 +218,9 @@ dependencies = [
"boa_engine",
"boa_gc",
"chrono",
"chrono-tz",
"clap",
"cron",
"dashmap 6.1.0",
"dirs 5.0.1",
"etcetera 0.11.0",
@@ -226,6 +228,7 @@ dependencies = [
"futures",
"glob",
"hex",
"hmac",
"hostname",
"ignore",
"include_dir",
@@ -1067,7 +1070,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"itertools 0.12.1",
"proc-macro2",
"quote",
"regex",
@@ -1603,6 +1606,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -2010,6 +2023,17 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
"winnow 0.6.26",
]
[[package]]
name = "croner"
version = "2.2.0"
@@ -4332,15 +4356,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -6033,6 +6048,15 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -6180,6 +6204,15 @@ dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -6602,7 +6635,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.50.0"
version = "0.51.0"
dependencies = [
"chrono",
"dirs 5.0.1",
@@ -6618,7 +6651,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.50.0"
version = "0.51.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -10964,6 +10997,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.14"
+4 -4
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.50.0"
version = "0.51.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.4.3"
# aster = { version = "0.4.3", path = "../../../astercloud/aster-rust/crates/aster" }
aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.4.3" }
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.5.1"
# aster = { version = "0.5.1", path = "../../../astercloud/aster-rust/crates/aster" }
aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.5.1" }
# Tauri
@@ -1,7 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 0d8594955233ffc968ac57a1a1d9dcff20ab597104e3a2787bca02618e9fcf06 # shrinks to provider = "qwen"
+2
View File
@@ -130,6 +130,8 @@ pub struct AgentSession {
pub messages: Vec<AgentMessage>,
/// 系统提示词
pub system_prompt: Option<String>,
/// 会话标题(可选,用于 UI 显示)
pub title: Option<String>,
/// 创建时间
pub created_at: String,
/// 最后活动时间
+26
View File
@@ -1061,6 +1061,8 @@ pub fn run() {
commands::agent_cmd::agent_get_session,
commands::agent_cmd::agent_delete_session,
commands::agent_cmd::agent_get_session_messages,
commands::agent_cmd::agent_rename_session,
commands::agent_cmd::agent_generate_title,
// TODO: 重新启用这些命令,适配 aster-rust 工具系统
// commands::agent_cmd::agent_terminal_command_response,
// commands::agent_cmd::agent_term_scrollback_response,
@@ -1234,6 +1236,30 @@ pub fn run() {
commands::workspace_cmd::workspace_set_default,
commands::workspace_cmd::workspace_get_default,
commands::workspace_cmd::workspace_get_by_path,
// Content commands
commands::content_cmd::content_create,
commands::content_cmd::content_get,
commands::content_cmd::content_list,
commands::content_cmd::content_update,
commands::content_cmd::content_delete,
commands::content_cmd::content_reorder,
commands::content_cmd::content_stats,
// Memory commands (Character, WorldBuilding, StyleGuide, Outline)
commands::memory_cmd::character_create,
commands::memory_cmd::character_get,
commands::memory_cmd::character_list,
commands::memory_cmd::character_update,
commands::memory_cmd::character_delete,
commands::memory_cmd::world_building_get,
commands::memory_cmd::world_building_update,
commands::memory_cmd::style_guide_get,
commands::memory_cmd::style_guide_update,
commands::memory_cmd::outline_node_create,
commands::memory_cmd::outline_node_get,
commands::memory_cmd::outline_node_list,
commands::memory_cmd::outline_node_update,
commands::memory_cmd::outline_node_delete,
commands::memory_cmd::project_memory_get,
// Context Memory commands
commands::context_memory::save_memory_entry,
commands::context_memory::get_session_memories,
+74
View File
@@ -146,6 +146,7 @@ pub async fn agent_create_session(
model: model_name.clone(),
messages: Vec::new(),
system_prompt: final_system_prompt,
title: None, // 初始会话没有标题,后续会自动生成
created_at: now.clone(),
updated_at: now,
};
@@ -231,6 +232,7 @@ pub struct SessionInfo {
pub session_id: String,
pub provider_type: String,
pub model: Option<String>,
pub title: Option<String>,
pub created_at: String,
pub last_activity: String,
pub messages_count: usize,
@@ -252,6 +254,7 @@ pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result<Vec<Sess
session_id: s.id,
provider_type: "aster".to_string(),
model: Some(s.model),
title: s.title,
created_at: s.created_at.clone(),
last_activity: s.updated_at,
messages_count,
@@ -280,6 +283,7 @@ pub async fn agent_get_session(
session_id: session.id,
provider_type: "aster".to_string(),
model: Some(session.model),
title: session.title,
created_at: session.created_at.clone(),
last_activity: session.updated_at,
messages_count,
@@ -308,3 +312,73 @@ pub async fn agent_get_session_messages(
AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {}", e))?;
Ok(messages)
}
/// 重命名会话(更新标题)
#[tauri::command]
pub async fn agent_rename_session(
db: State<'_, DbConnection>,
session_id: String,
title: String,
) -> Result<(), String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?;
AgentDao::update_title(&conn, &session_id, &title)
.map_err(|e| format!("更新会话标题失败: {}", e))?;
Ok(())
}
/// 生成智能标题
///
/// 根据对话内容生成一个简洁的标题
#[tauri::command]
pub async fn agent_generate_title(
db: State<'_, DbConnection>,
session_id: String,
) -> Result<String, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?;
// 获取会话的前几条消息(用于生成标题)
let messages =
AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {}", e))?;
// 过滤出 user 和 assistant 消息
let chat_messages: Vec<_> = messages
.iter()
.filter(|msg| msg.role == "user" || msg.role == "assistant")
.take(4) // 取前 2 轮对话
.collect();
if chat_messages.len() < 2 {
return Ok("新话题".to_string());
}
// 构建对话内容用于 AI 生成标题
let mut conversation = String::new();
for msg in &chat_messages {
let role = if msg.role == "user" {
"用户"
} else {
"助手"
};
let content = msg.content.as_text();
let truncated_content = if content.len() > 100 {
format!("{}...", &content[..100])
} else {
content
};
conversation.push_str(&format!("{}:{}\n", role, truncated_content));
}
// 使用 AI 生成标题(通过 aster_agent_chat_stream 生成)
// 这里简化处理:使用第一条用户消息的前 15 个字作为默认标题
if let Some(first_user_msg) = chat_messages.iter().find(|msg| msg.role == "user") {
let content = first_user_msg.content.as_text();
let title = if content.len() > 15 {
format!("{}...", &content[..15])
} else {
content
};
Ok(title)
} else {
Ok("新话题".to_string())
}
}
+237
View File
@@ -0,0 +1,237 @@
//! Content 相关的 Tauri 命令
//!
//! 提供内容管理的前端 API。
use crate::content::{
Content, ContentCreateRequest, ContentListQuery, ContentManager, ContentStatus,
ContentUpdateRequest,
};
use crate::database::DbConnection;
use serde::{Deserialize, Serialize};
use tauri::State;
/// 内容列表项(用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentListItem {
pub id: String,
pub project_id: String,
pub title: String,
pub content_type: String,
pub status: String,
pub order: i32,
pub word_count: i64,
pub created_at: i64,
pub updated_at: i64,
}
impl From<Content> for ContentListItem {
fn from(content: Content) -> Self {
Self {
id: content.id,
project_id: content.project_id,
title: content.title,
content_type: content.content_type.as_str().to_string(),
status: content.status.as_str().to_string(),
order: content.order,
word_count: content.word_count,
created_at: content.created_at.timestamp_millis(),
updated_at: content.updated_at.timestamp_millis(),
}
}
}
/// 内容详情(包含正文)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentDetail {
pub id: String,
pub project_id: String,
pub title: String,
pub content_type: String,
pub status: String,
pub order: i32,
pub body: String,
pub word_count: i64,
pub metadata: Option<serde_json::Value>,
pub session_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl From<Content> for ContentDetail {
fn from(content: Content) -> Self {
Self {
id: content.id,
project_id: content.project_id,
title: content.title,
content_type: content.content_type.as_str().to_string(),
status: content.status.as_str().to_string(),
order: content.order,
body: content.body,
word_count: content.word_count,
metadata: content.metadata,
session_id: content.session_id,
created_at: content.created_at.timestamp_millis(),
updated_at: content.updated_at.timestamp_millis(),
}
}
}
/// 创建内容请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateContentRequest {
pub project_id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
/// 更新内容请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateContentRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
/// 内容列表查询请求
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ListContentRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_order: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<i64>,
}
/// 创建内容
#[tauri::command]
pub async fn content_create(
db: State<'_, DbConnection>,
request: CreateContentRequest,
) -> Result<ContentDetail, String> {
let manager = ContentManager::new(db.inner().clone());
let create_request = ContentCreateRequest {
project_id: request.project_id,
title: request.title,
content_type: request
.content_type
.map(|s| crate::content::ContentType::from_str(&s)),
order: request.order,
body: request.body,
metadata: request.metadata,
};
let content = manager.create(create_request)?;
Ok(content.into())
}
/// 获取内容详情
#[tauri::command]
pub async fn content_get(
db: State<'_, DbConnection>,
id: String,
) -> Result<Option<ContentDetail>, String> {
let manager = ContentManager::new(db.inner().clone());
let content = manager.get(&id)?;
Ok(content.map(|c| c.into()))
}
/// 列出项目的所有内容
#[tauri::command]
pub async fn content_list(
db: State<'_, DbConnection>,
project_id: String,
query: Option<ListContentRequest>,
) -> Result<Vec<ContentListItem>, String> {
let manager = ContentManager::new(db.inner().clone());
let list_query = query.map(|q| ContentListQuery {
status: q.status.map(|s| ContentStatus::from_str(&s)),
content_type: q
.content_type
.map(|s| crate::content::ContentType::from_str(&s)),
search: q.search,
sort_by: q.sort_by,
sort_order: q.sort_order,
offset: q.offset,
limit: q.limit,
});
let contents = manager.list_by_project(&project_id, list_query)?;
Ok(contents.into_iter().map(|c| c.into()).collect())
}
/// 更新内容
#[tauri::command]
pub async fn content_update(
db: State<'_, DbConnection>,
id: String,
request: UpdateContentRequest,
) -> Result<ContentDetail, String> {
let manager = ContentManager::new(db.inner().clone());
let update_request = ContentUpdateRequest {
title: request.title,
status: request.status.map(|s| ContentStatus::from_str(&s)),
order: request.order,
body: request.body,
metadata: request.metadata,
session_id: request.session_id,
};
let content = manager.update(&id, update_request)?;
Ok(content.into())
}
/// 删除内容
#[tauri::command]
pub async fn content_delete(db: State<'_, DbConnection>, id: String) -> Result<bool, String> {
let manager = ContentManager::new(db.inner().clone());
manager.delete(&id)
}
/// 重新排序内容
#[tauri::command]
pub async fn content_reorder(
db: State<'_, DbConnection>,
project_id: String,
content_ids: Vec<String>,
) -> Result<(), String> {
let manager = ContentManager::new(db.inner().clone());
manager.reorder(&project_id, content_ids)
}
/// 获取项目内容统计
#[tauri::command]
pub async fn content_stats(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<(i64, i64, i64), String> {
let manager = ContentManager::new(db.inner().clone());
manager.get_project_stats(&project_id)
}
+226
View File
@@ -0,0 +1,226 @@
//! Memory 相关的 Tauri 命令
//!
//! 提供项目记忆系统(角色、世界观、风格指南、大纲)的前端 API。
use crate::database::DbConnection;
use crate::memory::{
Character, CharacterCreateRequest, CharacterUpdateRequest, MemoryManager, OutlineNode,
OutlineNodeCreateRequest, OutlineNodeUpdateRequest, ProjectMemory, StyleGuide,
StyleGuideUpdateRequest, WorldBuilding, WorldBuildingUpdateRequest,
};
use serde::{Deserialize, Serialize};
use tauri::State;
// ==================== 角色相关命令 ====================
/// 创建角色请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCharacterRequest {
pub project_id: String,
pub name: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub appearance: Option<String>,
#[serde(default)]
pub is_main: bool,
}
/// 创建角色
#[tauri::command]
pub async fn character_create(
db: State<'_, DbConnection>,
request: CreateCharacterRequest,
) -> Result<Character, String> {
let manager = MemoryManager::new(db.inner().clone());
let create_request = CharacterCreateRequest {
project_id: request.project_id,
name: request.name,
aliases: request.aliases,
description: request.description,
personality: request.personality,
background: request.background,
appearance: request.appearance,
is_main: request.is_main,
};
manager.create_character(create_request)
}
/// 获取角色
#[tauri::command]
pub async fn character_get(
db: State<'_, DbConnection>,
id: String,
) -> Result<Option<Character>, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.get_character(&id)
}
/// 列出项目的所有角色
#[tauri::command]
pub async fn character_list(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<Vec<Character>, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.list_characters(&project_id)
}
/// 更新角色
#[tauri::command]
pub async fn character_update(
db: State<'_, DbConnection>,
id: String,
request: CharacterUpdateRequest,
) -> Result<Character, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.update_character(&id, request)
}
/// 删除角色
#[tauri::command]
pub async fn character_delete(db: State<'_, DbConnection>, id: String) -> Result<bool, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.delete_character(&id)
}
// ==================== 世界观相关命令 ====================
/// 获取世界观
#[tauri::command]
pub async fn world_building_get(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<Option<WorldBuilding>, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.get_world_building(&project_id)
}
/// 更新世界观
#[tauri::command]
pub async fn world_building_update(
db: State<'_, DbConnection>,
project_id: String,
request: WorldBuildingUpdateRequest,
) -> Result<WorldBuilding, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.upsert_world_building(&project_id, request)
}
// ==================== 风格指南相关命令 ====================
/// 获取风格指南
#[tauri::command]
pub async fn style_guide_get(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<Option<StyleGuide>, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.get_style_guide(&project_id)
}
/// 更新风格指南
#[tauri::command]
pub async fn style_guide_update(
db: State<'_, DbConnection>,
project_id: String,
request: StyleGuideUpdateRequest,
) -> Result<StyleGuide, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.upsert_style_guide(&project_id, request)
}
// ==================== 大纲相关命令 ====================
/// 创建大纲节点请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateOutlineNodeRequest {
pub project_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
}
/// 创建大纲节点
#[tauri::command]
pub async fn outline_node_create(
db: State<'_, DbConnection>,
request: CreateOutlineNodeRequest,
) -> Result<OutlineNode, String> {
let manager = MemoryManager::new(db.inner().clone());
let create_request = OutlineNodeCreateRequest {
project_id: request.project_id,
parent_id: request.parent_id,
title: request.title,
content: request.content,
content_id: request.content_id,
order: request.order,
};
manager.create_outline_node(create_request)
}
/// 获取大纲节点
#[tauri::command]
pub async fn outline_node_get(
db: State<'_, DbConnection>,
id: String,
) -> Result<Option<OutlineNode>, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.get_outline_node(&id)
}
/// 列出项目的所有大纲节点
#[tauri::command]
pub async fn outline_node_list(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<Vec<OutlineNode>, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.list_outline_nodes(&project_id)
}
/// 更新大纲节点
#[tauri::command]
pub async fn outline_node_update(
db: State<'_, DbConnection>,
id: String,
request: OutlineNodeUpdateRequest,
) -> Result<OutlineNode, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.update_outline_node(&id, request)
}
/// 删除大纲节点
#[tauri::command]
pub async fn outline_node_delete(db: State<'_, DbConnection>, id: String) -> Result<bool, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.delete_outline_node(&id)
}
// ==================== 聚合查询命令 ====================
/// 获取项目的完整记忆
#[tauri::command]
pub async fn project_memory_get(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<ProjectMemory, String> {
let manager = MemoryManager::new(db.inner().clone());
manager.get_project_memory(&project_id)
}
+2
View File
@@ -7,6 +7,7 @@ pub mod browser_interceptor_cmd;
pub mod config_cmd;
pub mod connect_cmd;
pub mod connection_cmd;
pub mod content_cmd;
pub mod context_memory;
pub mod flow_monitor_cmd;
pub mod general_chat_cmd;
@@ -14,6 +15,7 @@ pub mod injection_cmd;
pub mod kiro_local;
pub mod machine_id_cmd;
pub mod mcp_cmd;
pub mod memory_cmd;
pub mod model_cmd;
pub mod model_registry_cmd;
pub mod models_cmd;
+42 -1
View File
@@ -36,6 +36,11 @@ pub struct WorkspaceListItem {
pub is_default: bool,
pub created_at: i64,
pub updated_at: i64,
pub icon: Option<String>,
pub color: Option<String>,
pub is_favorite: bool,
pub is_archived: bool,
pub tags: Vec<String>,
}
impl From<Workspace> for WorkspaceListItem {
@@ -48,6 +53,11 @@ impl From<Workspace> for WorkspaceListItem {
is_default: ws.is_default,
created_at: ws.created_at.timestamp_millis(),
updated_at: ws.updated_at.timestamp_millis(),
icon: ws.icon,
color: ws.color,
is_favorite: ws.is_favorite,
is_archived: ws.is_archived,
tags: ws.tags,
}
}
}
@@ -70,6 +80,16 @@ pub struct UpdateWorkspaceRequest {
pub name: Option<String>,
#[serde(default)]
pub settings: Option<WorkspaceSettings>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub is_favorite: Option<bool>,
#[serde(default)]
pub is_archived: Option<bool>,
#[serde(default)]
pub tags: Option<Vec<String>>,
}
// ==================== Tauri 命令 ====================
@@ -127,6 +147,11 @@ pub async fn workspace_update(
let updates = WorkspaceUpdate {
name: request.name,
settings: request.settings,
icon: request.icon,
color: request.color,
is_favorite: request.is_favorite,
is_archived: request.is_archived,
tags: request.tags,
};
let workspace = manager.update(&id, updates)?;
@@ -135,8 +160,24 @@ pub async fn workspace_update(
/// 删除 workspace
#[tauri::command]
pub async fn workspace_delete(db: State<'_, DbConnection>, id: String) -> Result<bool, String> {
pub async fn workspace_delete(
db: State<'_, DbConnection>,
id: String,
delete_directory: Option<bool>,
) -> Result<bool, String> {
let manager = WorkspaceManager::new(db.inner().clone());
// 如果需要删除目录,先获取 workspace 信息
if delete_directory.unwrap_or(false) {
if let Some(workspace) = manager.get(&id)? {
let root_path = workspace.root_path;
if root_path.exists() && root_path.is_dir() {
std::fs::remove_dir_all(&root_path).map_err(|e| format!("删除目录失败: {}", e))?;
tracing::info!("[Workspace] 删除目录: {:?}", root_path);
}
}
}
manager.delete(&id)
}
+462
View File
@@ -0,0 +1,462 @@
//! Content 管理器
//!
//! 提供 Content 的 CRUD 操作。
use super::types::{
Content, ContentCreateRequest, ContentId, ContentListQuery, ContentStatus, ContentType,
ContentUpdateRequest,
};
use crate::database::DbConnection;
use crate::workspace::WorkspaceType;
use chrono::Utc;
use rusqlite::params;
use uuid::Uuid;
/// Content 管理器
#[derive(Clone)]
pub struct ContentManager {
db: DbConnection,
}
impl ContentManager {
/// 创建新的 ContentManager
pub fn new(db: DbConnection) -> Self {
Self { db }
}
/// 创建新内容
pub fn create(&self, request: ContentCreateRequest) -> Result<Content, String> {
let now = Utc::now();
let id = Uuid::new_v4().to_string();
// 获取下一个排序顺序
let order = match request.order {
Some(o) => o,
None => self.get_next_order(&request.project_id)?,
};
let body = request.body.unwrap_or_default();
let word_count = count_words(&body);
let content_type = match request.content_type {
Some(content_type) => content_type,
None => self.get_default_content_type(&request.project_id),
};
let metadata_json = request
.metadata
.as_ref()
.map(|m| serde_json::to_string(m).unwrap_or_default());
let content = Content {
id: id.clone(),
project_id: request.project_id.clone(),
title: request.title,
content_type: content_type.clone(),
status: ContentStatus::Draft,
order,
body,
word_count,
metadata: request.metadata,
session_id: None,
created_at: now,
updated_at: now,
};
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
conn.execute(
"INSERT INTO contents (id, project_id, title, content_type, status, sort_order, body, word_count, metadata_json, session_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
&content.id,
&content.project_id,
&content.title,
content.content_type.as_str(),
content.status.as_str(),
content.order,
&content.body,
content.word_count,
&metadata_json,
&content.session_id,
content.created_at.timestamp_millis(),
content.updated_at.timestamp_millis(),
],
)
.map_err(|e| format!("创建内容失败: {}", e))?;
tracing::info!(
"[Content] 创建: id={}, project_id={}, title={}",
content.id,
content.project_id,
content.title
);
Ok(content)
}
/// 获取项目默认内容类型(兜底)
fn get_default_content_type(&self, project_id: &str) -> ContentType {
let conn = match self.db.lock() {
Ok(conn) => conn,
Err(_) => return ContentType::Document,
};
let workspace_type: Result<String, _> = conn.query_row(
"SELECT workspace_type FROM workspaces WHERE id = ?",
params![project_id],
|row| row.get(0),
);
let workspace_type = match workspace_type {
Ok(value) => WorkspaceType::from_str(&value),
Err(_) => return ContentType::Document,
};
match workspace_type {
WorkspaceType::Video => ContentType::Episode,
WorkspaceType::Novel => ContentType::Chapter,
WorkspaceType::SocialMedia => ContentType::Post,
WorkspaceType::General => ContentType::Content,
WorkspaceType::Document => ContentType::Document,
WorkspaceType::Poster
| WorkspaceType::Music
| WorkspaceType::Knowledge
| WorkspaceType::Planning
| WorkspaceType::Persistent
| WorkspaceType::Temporary => ContentType::Document,
}
}
/// 获取内容
pub fn get(&self, id: &ContentId) -> Result<Option<Content>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT id, project_id, title, content_type, status, sort_order, body, word_count, metadata_json, session_id, created_at, updated_at
FROM contents WHERE id = ?",
params![id],
|row| Ok(Self::row_to_content(row)?),
);
match result {
Ok(content) => Ok(Some(content)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("获取内容失败: {}", e)),
}
}
/// 列出项目下的所有内容
pub fn list_by_project(
&self,
project_id: &str,
query: Option<ContentListQuery>,
) -> Result<Vec<Content>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let query = query.unwrap_or_default();
// 构建查询
let mut sql = String::from(
"SELECT id, project_id, title, content_type, status, sort_order, body, word_count, metadata_json, session_id, created_at, updated_at
FROM contents WHERE project_id = ?",
);
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project_id.to_string())];
// 状态过滤
if let Some(ref status) = query.status {
sql.push_str(" AND status = ?");
params_vec.push(Box::new(status.as_str().to_string()));
}
// 内容类型过滤
if let Some(ref content_type) = query.content_type {
sql.push_str(" AND content_type = ?");
params_vec.push(Box::new(content_type.as_str().to_string()));
}
// 搜索
if let Some(ref search) = query.search {
sql.push_str(" AND (title LIKE ? OR body LIKE ?)");
let search_pattern = format!("%{}%", search);
params_vec.push(Box::new(search_pattern.clone()));
params_vec.push(Box::new(search_pattern));
}
// 排序
let sort_by = query.sort_by.unwrap_or_else(|| "sort_order".to_string());
let sort_order = query.sort_order.unwrap_or_else(|| "asc".to_string());
sql.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order));
// 分页
if let Some(limit) = query.limit {
sql.push_str(" LIMIT ?");
params_vec.push(Box::new(limit));
}
if let Some(offset) = query.offset {
sql.push_str(" OFFSET ?");
params_vec.push(Box::new(offset));
}
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("准备查询失败: {}", e))?;
let contents = stmt
.query_map(params_refs.as_slice(), |row| Ok(Self::row_to_content(row)?))
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(contents)
}
/// 更新内容
pub fn update(&self, id: &ContentId, updates: ContentUpdateRequest) -> Result<Content, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let now = Utc::now().timestamp_millis();
// 构建更新语句
let mut set_clauses = vec!["updated_at = ?"];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
if let Some(ref title) = updates.title {
set_clauses.push("title = ?");
params_vec.push(Box::new(title.clone()));
}
if let Some(ref status) = updates.status {
set_clauses.push("status = ?");
params_vec.push(Box::new(status.as_str().to_string()));
}
if let Some(order) = updates.order {
set_clauses.push("sort_order = ?");
params_vec.push(Box::new(order));
}
if let Some(ref body) = updates.body {
set_clauses.push("body = ?");
params_vec.push(Box::new(body.clone()));
set_clauses.push("word_count = ?");
params_vec.push(Box::new(count_words(body)));
}
if let Some(ref metadata) = updates.metadata {
let metadata_json = serde_json::to_string(metadata).map_err(|e| e.to_string())?;
set_clauses.push("metadata_json = ?");
params_vec.push(Box::new(metadata_json));
}
if let Some(ref session_id) = updates.session_id {
set_clauses.push("session_id = ?");
params_vec.push(Box::new(session_id.clone()));
}
params_vec.push(Box::new(id.clone()));
let sql = format!(
"UPDATE contents SET {} WHERE id = ?",
set_clauses.join(", ")
);
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice())
.map_err(|e| format!("更新内容失败: {}", e))?;
drop(conn);
self.get(id)?.ok_or_else(|| "内容不存在".to_string())
}
/// 删除内容
pub fn delete(&self, id: &ContentId) -> Result<bool, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let affected = conn
.execute("DELETE FROM contents WHERE id = ?", params![id])
.map_err(|e| format!("删除内容失败: {}", e))?;
if affected > 0 {
tracing::info!("[Content] 删除: id={}", id);
}
Ok(affected > 0)
}
/// 批量删除项目下的所有内容
pub fn delete_by_project(&self, project_id: &str) -> Result<i64, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let affected = conn
.execute(
"DELETE FROM contents WHERE project_id = ?",
params![project_id],
)
.map_err(|e| format!("删除内容失败: {}", e))?;
tracing::info!(
"[Content] 批量删除: project_id={}, count={}",
project_id,
affected
);
Ok(affected as i64)
}
/// 获取项目的内容统计
pub fn get_project_stats(&self, project_id: &str) -> Result<(i64, i64, i64), String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(word_count), 0), COUNT(CASE WHEN status = 'completed' THEN 1 END)
FROM contents WHERE project_id = ?",
params![project_id],
|row| {
let count: i64 = row.get(0)?;
let words: i64 = row.get(1)?;
let completed: i64 = row.get(2)?;
Ok((count, words, completed))
},
);
match result {
Ok(stats) => Ok(stats),
Err(e) => Err(format!("获取统计失败: {}", e)),
}
}
/// 重新排序内容
pub fn reorder(&self, project_id: &str, content_ids: Vec<String>) -> Result<(), String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
for (index, content_id) in content_ids.iter().enumerate() {
conn.execute(
"UPDATE contents SET sort_order = ?, updated_at = ? WHERE id = ? AND project_id = ?",
params![
index as i32,
Utc::now().timestamp_millis(),
content_id,
project_id
],
)
.map_err(|e| format!("重新排序失败: {}", e))?;
}
Ok(())
}
/// 获取下一个排序顺序
fn get_next_order(&self, project_id: &str) -> Result<i32, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result: Result<i32, _> = conn.query_row(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM contents WHERE project_id = ?",
params![project_id],
|row| row.get(0),
);
result.map_err(|e| format!("获取排序顺序失败: {}", e))
}
/// 从数据库行解析 Content
fn row_to_content(row: &rusqlite::Row) -> Result<Content, rusqlite::Error> {
let id: String = row.get(0)?;
let project_id: String = row.get(1)?;
let title: String = row.get(2)?;
let content_type_str: String = row.get(3)?;
let status_str: String = row.get(4)?;
let order: i32 = row.get(5)?;
let body: String = row.get(6)?;
let word_count: i64 = row.get(7)?;
let metadata_json: Option<String> = row.get(8)?;
let session_id: Option<String> = row.get(9)?;
let created_at_ms: i64 = row.get(10)?;
let updated_at_ms: i64 = row.get(11)?;
let metadata = metadata_json.and_then(|s| serde_json::from_str(&s).ok());
Ok(Content {
id,
project_id,
title,
content_type: ContentType::from_str(&content_type_str),
status: ContentStatus::from_str(&status_str),
order,
body,
word_count,
metadata,
session_id,
created_at: chrono::DateTime::from_timestamp_millis(created_at_ms)
.unwrap_or_else(Utc::now),
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms)
.unwrap_or_else(Utc::now),
})
}
}
/// 计算字数(支持中英文混合)
fn count_words(text: &str) -> i64 {
let mut count = 0i64;
let mut in_word = false;
for c in text.chars() {
if c.is_whitespace() {
in_word = false;
} else if c.is_ascii_alphanumeric() {
if !in_word {
count += 1;
in_word = true;
}
} else if !c.is_ascii_punctuation() {
// 非 ASCII 字符(如中文)每个字符计为一个字
count += 1;
in_word = false;
}
}
count
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_count_words() {
assert_eq!(count_words("hello world"), 2);
assert_eq!(count_words("你好世界"), 4);
assert_eq!(count_words("hello 世界"), 3);
assert_eq!(count_words(""), 0);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Content 模块
//!
//! 提供项目内容管理功能。
pub mod manager;
pub mod types;
pub use manager::ContentManager;
pub use types::*;
+213
View File
@@ -0,0 +1,213 @@
//! Content 类型定义
//!
//! 定义项目内容相关的数据结构和类型。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Content 唯一标识
pub type ContentId = String;
/// 内容类型
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ContentType {
/// 剧集(短剧项目)
Episode,
/// 章节(小说项目)
Chapter,
/// 帖子(社媒项目)
Post,
/// 文档(文档项目)
#[default]
Document,
/// 通用内容
Content,
}
impl ContentType {
pub fn as_str(&self) -> &'static str {
match self {
ContentType::Episode => "episode",
ContentType::Chapter => "chapter",
ContentType::Post => "post",
ContentType::Document => "document",
ContentType::Content => "content",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"episode" => ContentType::Episode,
"chapter" => ContentType::Chapter,
"post" => ContentType::Post,
"document" => ContentType::Document,
"content" => ContentType::Content,
_ => ContentType::Document,
}
}
/// 获取内容类型的显示名称
pub fn display_name(&self) -> &'static str {
match self {
ContentType::Episode => "剧集",
ContentType::Chapter => "章节",
ContentType::Post => "帖子",
ContentType::Document => "文档",
ContentType::Content => "内容",
}
}
}
/// 内容状态
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ContentStatus {
/// 草稿
#[default]
Draft,
/// 已完成
Completed,
/// 已发布
Published,
}
impl ContentStatus {
pub fn as_str(&self) -> &'static str {
match self {
ContentStatus::Draft => "draft",
ContentStatus::Completed => "completed",
ContentStatus::Published => "published",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"draft" => ContentStatus::Draft,
"completed" => ContentStatus::Completed,
"published" => ContentStatus::Published,
_ => ContentStatus::Draft,
}
}
}
/// 内容实体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Content {
/// 唯一标识
pub id: ContentId,
/// 所属项目 ID
pub project_id: String,
/// 标题
pub title: String,
/// 内容类型
pub content_type: ContentType,
/// 状态
pub status: ContentStatus,
/// 排序顺序
pub order: i32,
/// 正文内容
pub body: String,
/// 字数统计
pub word_count: i64,
/// 类型特定的元数据(JSON)
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
/// 关联的 AI 会话 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
/// 内容创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentCreateRequest {
/// 所属项目 ID
pub project_id: String,
/// 标题
pub title: String,
/// 内容类型(可选,默认根据项目类型推断)
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<ContentType>,
/// 排序顺序(可选,默认追加到末尾)
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
/// 初始正文内容
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// 元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
/// 内容更新请求
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContentUpdateRequest {
/// 新标题
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// 新状态
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ContentStatus>,
/// 新排序顺序
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
/// 新正文内容
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// 新元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
/// 关联的 AI 会话 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
/// 内容列表查询参数
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContentListQuery {
/// 按状态过滤
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ContentStatus>,
/// 按内容类型过滤
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<ContentType>,
/// 搜索关键词
#[serde(skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
/// 排序字段
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_by: Option<String>,
/// 排序方向(asc/desc)
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_order: Option<String>,
/// 分页:偏移量
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<i64>,
/// 分页:限制数量
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<i64>,
}
/// 内容版本(用于版本历史)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentVersion {
/// 版本 ID
pub id: String,
/// 内容 ID
pub content_id: ContentId,
/// 版本号
pub version: i32,
/// 正文内容
pub body: String,
/// 字数统计
pub word_count: i64,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 备注
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
+25
View File
@@ -15,6 +15,7 @@
- `cw_to_openai.rs` - CodeWhisperer → OpenAI 转换
- `anthropic_to_openai.rs` - Anthropic → OpenAI 转换
- `openai_to_antigravity.rs` - OpenAI → Antigravity (Gemini CLI) 转换
- `reasoning_handler.rs` - 推理内容处理器(DeepSeek/OpenAI o1 等)
## 工具类型支持
@@ -25,6 +26,29 @@
- `web_search`: 联网搜索工具(Codex/Kiro 格式)
- `web_search_20250305`: 联网搜索工具(Claude Code 格式)
## 推理内容处理
### 支持的模型
| 模型 | 字段名 | 多轮对话处理 |
|------|--------|--------------|
| DeepSeek R1/Reasoner | `reasoning_content` | 丢弃历史,只保留最后一条 |
| OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 |
### 使用方式
```rust
use crate::converter::ReasoningHandler;
// 预处理消息,清理历史 reasoning_content
let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner");
// 检查模型是否需要清理
if ReasoningHandler::needs_reasoning_cleanup(&model) {
// 执行清理逻辑
}
```
## Antigravity 转换说明
参考 CLIProxyAPI 实现,主要特性:
@@ -36,6 +60,7 @@
## 更新日志
- 2026-02-01: 添加 reasoning_handler 模块,支持 DeepSeek/OpenAI 推理模型
- 2025-12-28: 修复 Antigravity 转换,对齐 CLIProxyAPI 实现
- 2025-12-27: 添加 web_search 工具支持,修复 Issue #49
+3
View File
@@ -3,6 +3,7 @@ pub mod cw_to_openai;
pub mod openai_to_antigravity;
pub mod openai_to_cw;
pub mod protocol_selector;
pub mod reasoning_handler;
#[allow(unused_imports)]
pub use anthropic_to_openai::*;
@@ -14,3 +15,5 @@ pub use openai_to_antigravity::*;
pub use openai_to_cw::*;
#[allow(unused_imports)]
pub use protocol_selector::*;
#[allow(unused_imports)]
pub use reasoning_handler::*;
@@ -0,0 +1,219 @@
//! 推理内容处理器
//!
//! 处理不同模型的推理/思考内容(reasoning_content)在多轮对话中的传递规则。
//!
//! # 支持的模型
//!
//! | 模型 | 字段名 | 多轮对话处理 |
//! |------|--------|--------------|
//! | DeepSeek R1/Reasoner | `reasoning_content` | 丢弃,只保留 `content` |
//! | OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 |
//!
//! # 设计原则
//!
//! 根据 DeepSeek API 文档:
//! - 多轮对话时,`reasoning_content` 不应传递给下一轮
//! - 只有 `content` 字段需要保留在对话历史中
//! - Tool Calls 场景下,需要正确处理 reasoning_content 的传递
//!
use crate::models::openai::ChatMessage;
/// 模型类型,用于确定推理内容处理策略
#[derive(Debug, Clone, PartialEq)]
pub enum ReasoningModelType {
/// DeepSeek R1/Reasoner 系列
DeepSeek,
/// OpenAI o1/o3/o4 系列
OpenAI,
/// 其他模型(不处理)
Other,
}
impl ReasoningModelType {
/// 从模型名称检测模型类型
pub fn from_model_name(model: &str) -> Self {
let model_lower = model.to_lowercase();
if model_lower.contains("deepseek")
&& (model_lower.contains("reasoner") || model_lower.contains("r1"))
{
Self::DeepSeek
} else if model_lower.starts_with("o1")
|| model_lower.starts_with("o3")
|| model_lower.starts_with("o4")
{
Self::OpenAI
} else {
Self::Other
}
}
}
/// 推理内容处理器
pub struct ReasoningHandler;
impl ReasoningHandler {
/// 预处理消息列表,根据模型类型清理 reasoning_content
///
/// # DeepSeek 处理规则
///
/// 根据 DeepSeek API 文档:
/// - 多轮对话时,历史消息中的 `reasoning_content` 应该被丢弃
/// - 只保留 `content` 字段用于上下文
/// - 这样可以节省网络带宽,避免 400 错误
///
/// # 参数
///
/// - `messages`: 消息列表
/// - `model`: 模型名称
///
/// # 返回
///
/// 处理后的消息列表
pub fn preprocess_messages(messages: Vec<ChatMessage>, model: &str) -> Vec<ChatMessage> {
let model_type = ReasoningModelType::from_model_name(model);
match model_type {
ReasoningModelType::DeepSeek => Self::process_deepseek_messages(messages),
ReasoningModelType::OpenAI => Self::process_openai_messages(messages),
ReasoningModelType::Other => messages,
}
}
/// 处理 DeepSeek 消息
///
/// 清除历史消息中的 reasoning_content,只保留最后一条 assistant 消息的 reasoning_content
fn process_deepseek_messages(mut messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let len = messages.len();
// 先找出最后一条 assistant 消息的索引
let last_assistant_idx = messages
.iter()
.enumerate()
.rev()
.find(|(_, m)| m.role == "assistant")
.map(|(i, _)| i);
for (i, msg) in messages.iter_mut().enumerate() {
// 只处理 assistant 消息
if msg.role != "assistant" {
continue;
}
// 保留最后一条 assistant 消息的 reasoning_content(如果有 tool_calls)
// 因为 DeepSeek 在 tool calls 场景下需要这个字段
let is_last_assistant = Some(i) == last_assistant_idx;
if !is_last_assistant {
// 清除非最后一条 assistant 消息的 reasoning_content
msg.reasoning_content = None;
}
}
messages
}
/// 处理 OpenAI o1/o3 消息
///
/// OpenAI 的推理模型使用 previous_response_id 机制,
/// 目前不需要特殊处理消息内容
fn process_openai_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// OpenAI 推理模型目前不需要特殊处理
// 未来可能需要处理 reasoning 字段
messages
}
/// 检查模型是否支持推理模式
pub fn supports_reasoning(model: &str) -> bool {
let model_type = ReasoningModelType::from_model_name(model);
!matches!(model_type, ReasoningModelType::Other)
}
/// 检查模型是否需要清理历史 reasoning_content
pub fn needs_reasoning_cleanup(model: &str) -> bool {
matches!(
ReasoningModelType::from_model_name(model),
ReasoningModelType::DeepSeek
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::openai::MessageContent;
#[test]
fn test_model_type_detection() {
assert_eq!(
ReasoningModelType::from_model_name("deepseek-reasoner"),
ReasoningModelType::DeepSeek
);
assert_eq!(
ReasoningModelType::from_model_name("deepseek-r1"),
ReasoningModelType::DeepSeek
);
assert_eq!(
ReasoningModelType::from_model_name("DeepSeek-R1-0528"),
ReasoningModelType::DeepSeek
);
assert_eq!(
ReasoningModelType::from_model_name("o1-preview"),
ReasoningModelType::OpenAI
);
assert_eq!(
ReasoningModelType::from_model_name("o3-mini"),
ReasoningModelType::OpenAI
);
assert_eq!(
ReasoningModelType::from_model_name("gpt-4o"),
ReasoningModelType::Other
);
assert_eq!(
ReasoningModelType::from_model_name("deepseek-chat"),
ReasoningModelType::Other
);
}
#[test]
fn test_deepseek_reasoning_cleanup() {
let messages = vec![
ChatMessage {
role: "user".to_string(),
content: Some(MessageContent::Text("Hello".to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
},
ChatMessage {
role: "assistant".to_string(),
content: Some(MessageContent::Text("Hi there!".to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: Some("Thinking about greeting...".to_string()),
},
ChatMessage {
role: "user".to_string(),
content: Some(MessageContent::Text("How are you?".to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
},
ChatMessage {
role: "assistant".to_string(),
content: Some(MessageContent::Text("I'm doing well!".to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: Some("Thinking about response...".to_string()),
},
];
let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner");
// 第一条 assistant 消息的 reasoning_content 应该被清除
assert!(processed[1].reasoning_content.is_none());
// 最后一条 assistant 消息的 reasoning_content 应该保留
assert!(processed[3].reasoning_content.is_some());
}
}
+39 -8
View File
@@ -14,12 +14,13 @@ impl AgentDao {
session: &AgentSession,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
session.id,
session.model,
session.system_prompt,
session.title,
session.created_at,
session.updated_at,
],
@@ -33,7 +34,7 @@ impl AgentDao {
session_id: &str,
) -> Result<Option<AgentSession>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, created_at, updated_at
"SELECT id, model, system_prompt, title, created_at, updated_at
FROM agent_sessions WHERE id = ?",
)?;
@@ -45,8 +46,9 @@ impl AgentDao {
model: row.get(1)?,
messages: Vec::new(), // 消息需要单独加载
system_prompt: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
title: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
}))
} else {
Ok(None)
@@ -70,7 +72,7 @@ impl AgentDao {
/// 获取所有会话(不包含消息)
pub fn list_sessions(conn: &Connection) -> Result<Vec<AgentSession>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, created_at, updated_at
"SELECT id, model, system_prompt, title, created_at, updated_at
FROM agent_sessions ORDER BY updated_at DESC",
)?;
@@ -80,8 +82,9 @@ impl AgentDao {
model: row.get(1)?,
messages: Vec::new(),
system_prompt: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
title: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
})?;
@@ -226,4 +229,32 @@ impl AgentDao {
)?;
Ok(count > 0)
}
/// 更新会话标题
pub fn update_title(
conn: &Connection,
session_id: &str,
title: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE agent_sessions SET title = ? WHERE id = ?",
params![title, session_id],
)?;
Ok(())
}
/// 获取会话标题
pub fn get_title(
conn: &Connection,
session_id: &str,
) -> Result<Option<String>, rusqlite::Error> {
let mut stmt = conn.prepare("SELECT title FROM agent_sessions WHERE id = ?")?;
let mut rows = stmt.query([session_id])?;
if let Some(row) = rows.next()? {
Ok(row.get(0)?)
} else {
Ok(None)
}
}
}
+169
View File
@@ -450,12 +450,16 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
id TEXT PRIMARY KEY,
model TEXT NOT NULL,
system_prompt TEXT,
title TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
// Migration: 添加 title 列(如果不存在)
let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN title TEXT", []);
// Agent 消息表
// 存储每个会话的消息历史
conn.execute(
@@ -560,6 +564,171 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Migration: 添加项目管理相关字段到 workspaces 表
let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN icon TEXT", []);
let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN color TEXT", []);
let _ = conn.execute(
"ALTER TABLE workspaces ADD COLUMN is_favorite INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE workspaces ADD COLUMN is_archived INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE workspaces ADD COLUMN tags_json TEXT DEFAULT '[]'",
[],
);
// Migration: 迁移旧的项目类型到新类型
// drama -> video, social -> social-media
let _ = conn.execute(
"UPDATE workspaces SET workspace_type = 'video' WHERE workspace_type = 'drama'",
[],
);
let _ = conn.execute(
"UPDATE workspaces SET workspace_type = 'social-media' WHERE workspace_type = 'social'",
[],
);
// ============================================================================
// 项目内容管理相关表
// ============================================================================
// 内容表
// 存储项目下的内容(剧集、章节、帖子、文档等)
conn.execute(
"CREATE TABLE IF NOT EXISTS contents (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'document',
status TEXT NOT NULL DEFAULT 'draft',
sort_order INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL DEFAULT '',
word_count INTEGER NOT NULL DEFAULT 0,
metadata_json TEXT,
session_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
)",
[],
)?;
// 创建 contents 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contents_project_id ON contents(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contents_status ON contents(status)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contents_sort_order ON contents(sort_order)",
[],
)?;
// ============================================================================
// 项目记忆系统相关表
// ============================================================================
// 角色表
// 存储项目的角色设定
conn.execute(
"CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
aliases_json TEXT NOT NULL DEFAULT '[]',
description TEXT,
personality TEXT,
background TEXT,
appearance TEXT,
relationships_json TEXT NOT NULL DEFAULT '[]',
avatar_url TEXT,
is_main INTEGER DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
extra_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
)",
[],
)?;
// 创建 characters 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_characters_project_id ON characters(project_id)",
[],
)?;
// 世界观表
// 存储项目的世界观设定
conn.execute(
"CREATE TABLE IF NOT EXISTS world_building (
project_id TEXT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '',
era TEXT,
locations TEXT,
rules TEXT,
extra_json TEXT,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
)",
[],
)?;
// 风格指南表
// 存储项目的写作风格指南
conn.execute(
"CREATE TABLE IF NOT EXISTS style_guides (
project_id TEXT PRIMARY KEY,
style TEXT NOT NULL DEFAULT '',
tone TEXT,
forbidden_words_json TEXT NOT NULL DEFAULT '[]',
preferred_words_json TEXT NOT NULL DEFAULT '[]',
examples TEXT,
extra_json TEXT,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
)",
[],
)?;
// 大纲节点表
// 存储项目的大纲结构
conn.execute(
"CREATE TABLE IF NOT EXISTS outline_nodes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
title TEXT NOT NULL,
content TEXT,
content_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
expanded INTEGER DEFAULT 1,
extra_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES outline_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (content_id) REFERENCES contents(id) ON DELETE SET NULL
)",
[],
)?;
// 创建 outline_nodes 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_outline_nodes_project_id ON outline_nodes(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_outline_nodes_parent_id ON outline_nodes(parent_id)",
[],
)?;
Ok(())
}
+1
View File
@@ -209,6 +209,7 @@ pub async fn handle_command(
model: model_name.clone(),
messages: Vec::new(),
system_prompt,
title: None, // 初始会话没有标题,后续会自动生成
created_at: now.clone(),
updated_at: now,
};
+2
View File
@@ -32,9 +32,11 @@ pub mod app;
pub mod backends;
pub mod browser_interceptor;
pub mod connect;
pub mod content;
pub mod credential;
pub mod database;
pub mod flow_monitor;
pub mod memory;
pub mod orchestrator;
pub mod plugin;
pub mod screenshot;
+719
View File
@@ -0,0 +1,719 @@
//! Memory 管理器
//!
//! 提供项目记忆系统的 CRUD 操作(角色、世界观、风格指南、大纲)。
use super::types::*;
use crate::database::DbConnection;
use chrono::Utc;
use rusqlite::params;
use uuid::Uuid;
/// Memory 管理器
#[derive(Clone)]
pub struct MemoryManager {
db: DbConnection,
}
impl MemoryManager {
/// 创建新的 MemoryManager
pub fn new(db: DbConnection) -> Self {
Self { db }
}
// ==================== 角色管理 ====================
/// 创建角色
pub fn create_character(&self, request: CharacterCreateRequest) -> Result<Character, String> {
let now = Utc::now();
let id = Uuid::new_v4().to_string();
let order = self.get_next_character_order(&request.project_id)?;
let aliases_json = serde_json::to_string(&request.aliases).unwrap_or_default();
let character = Character {
id: id.clone(),
project_id: request.project_id.clone(),
name: request.name,
aliases: request.aliases,
description: request.description,
personality: request.personality,
background: request.background,
appearance: request.appearance,
relationships: Vec::new(),
avatar_url: None,
is_main: request.is_main,
order,
extra: None,
created_at: now,
updated_at: now,
};
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
conn.execute(
"INSERT INTO characters (id, project_id, name, aliases_json, description, personality, background, appearance, relationships_json, avatar_url, is_main, sort_order, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
&character.id,
&character.project_id,
&character.name,
&aliases_json,
&character.description,
&character.personality,
&character.background,
&character.appearance,
"[]",
&character.avatar_url,
character.is_main,
character.order,
Option::<String>::None,
character.created_at.timestamp_millis(),
character.updated_at.timestamp_millis(),
],
)
.map_err(|e| format!("创建角色失败: {}", e))?;
tracing::info!(
"[Memory] 创建角色: id={}, name={}",
character.id,
character.name
);
Ok(character)
}
/// 获取角色
pub fn get_character(&self, id: &CharacterId) -> Result<Option<Character>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT id, project_id, name, aliases_json, description, personality, background, appearance, relationships_json, avatar_url, is_main, sort_order, extra_json, created_at, updated_at
FROM characters WHERE id = ?",
params![id],
|row| Ok(Self::row_to_character(row)?),
);
match result {
Ok(character) => Ok(Some(character)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("获取角色失败: {}", e)),
}
}
/// 列出项目的所有角色
pub fn list_characters(&self, project_id: &str) -> Result<Vec<Character>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let mut stmt = conn
.prepare(
"SELECT id, project_id, name, aliases_json, description, personality, background, appearance, relationships_json, avatar_url, is_main, sort_order, extra_json, created_at, updated_at
FROM characters WHERE project_id = ? ORDER BY sort_order ASC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
let characters = stmt
.query_map(params![project_id], |row| Ok(Self::row_to_character(row)?))
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(characters)
}
/// 更新角色
pub fn update_character(
&self,
id: &CharacterId,
updates: CharacterUpdateRequest,
) -> Result<Character, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let now = Utc::now().timestamp_millis();
let mut set_clauses = vec!["updated_at = ?"];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
if let Some(ref name) = updates.name {
set_clauses.push("name = ?");
params_vec.push(Box::new(name.clone()));
}
if let Some(ref aliases) = updates.aliases {
set_clauses.push("aliases_json = ?");
params_vec.push(Box::new(serde_json::to_string(aliases).unwrap_or_default()));
}
if let Some(ref description) = updates.description {
set_clauses.push("description = ?");
params_vec.push(Box::new(description.clone()));
}
if let Some(ref personality) = updates.personality {
set_clauses.push("personality = ?");
params_vec.push(Box::new(personality.clone()));
}
if let Some(ref background) = updates.background {
set_clauses.push("background = ?");
params_vec.push(Box::new(background.clone()));
}
if let Some(ref appearance) = updates.appearance {
set_clauses.push("appearance = ?");
params_vec.push(Box::new(appearance.clone()));
}
if let Some(ref relationships) = updates.relationships {
set_clauses.push("relationships_json = ?");
params_vec.push(Box::new(
serde_json::to_string(relationships).unwrap_or_default(),
));
}
if let Some(ref avatar_url) = updates.avatar_url {
set_clauses.push("avatar_url = ?");
params_vec.push(Box::new(avatar_url.clone()));
}
if let Some(is_main) = updates.is_main {
set_clauses.push("is_main = ?");
params_vec.push(Box::new(is_main));
}
if let Some(order) = updates.order {
set_clauses.push("sort_order = ?");
params_vec.push(Box::new(order));
}
if let Some(ref extra) = updates.extra {
set_clauses.push("extra_json = ?");
params_vec.push(Box::new(serde_json::to_string(extra).unwrap_or_default()));
}
params_vec.push(Box::new(id.clone()));
let sql = format!(
"UPDATE characters SET {} WHERE id = ?",
set_clauses.join(", ")
);
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice())
.map_err(|e| format!("更新角色失败: {}", e))?;
drop(conn);
self.get_character(id)?
.ok_or_else(|| "角色不存在".to_string())
}
/// 删除角色
pub fn delete_character(&self, id: &CharacterId) -> Result<bool, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let affected = conn
.execute("DELETE FROM characters WHERE id = ?", params![id])
.map_err(|e| format!("删除角色失败: {}", e))?;
if affected > 0 {
tracing::info!("[Memory] 删除角色: id={}", id);
}
Ok(affected > 0)
}
fn get_next_character_order(&self, project_id: &str) -> Result<i32, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result: Result<i32, _> = conn.query_row(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM characters WHERE project_id = ?",
params![project_id],
|row| row.get(0),
);
result.map_err(|e| format!("获取排序顺序失败: {}", e))
}
fn row_to_character(row: &rusqlite::Row) -> Result<Character, rusqlite::Error> {
let id: String = row.get(0)?;
let project_id: String = row.get(1)?;
let name: String = row.get(2)?;
let aliases_json: String = row.get(3)?;
let description: Option<String> = row.get(4)?;
let personality: Option<String> = row.get(5)?;
let background: Option<String> = row.get(6)?;
let appearance: Option<String> = row.get(7)?;
let relationships_json: String = row.get(8)?;
let avatar_url: Option<String> = row.get(9)?;
let is_main: bool = row.get(10)?;
let order: i32 = row.get(11)?;
let extra_json: Option<String> = row.get(12)?;
let created_at_ms: i64 = row.get(13)?;
let updated_at_ms: i64 = row.get(14)?;
Ok(Character {
id,
project_id,
name,
aliases: serde_json::from_str(&aliases_json).unwrap_or_default(),
description,
personality,
background,
appearance,
relationships: serde_json::from_str(&relationships_json).unwrap_or_default(),
avatar_url,
is_main,
order,
extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()),
created_at: chrono::DateTime::from_timestamp_millis(created_at_ms)
.unwrap_or_else(Utc::now),
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms)
.unwrap_or_else(Utc::now),
})
}
// ==================== 世界观管理 ====================
/// 获取或创建世界观
pub fn get_world_building(&self, project_id: &str) -> Result<Option<WorldBuilding>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT project_id, description, era, locations, rules, extra_json, updated_at
FROM world_building WHERE project_id = ?",
params![project_id],
|row| {
let project_id: String = row.get(0)?;
let description: String = row.get(1)?;
let era: Option<String> = row.get(2)?;
let locations: Option<String> = row.get(3)?;
let rules: Option<String> = row.get(4)?;
let extra_json: Option<String> = row.get(5)?;
let updated_at_ms: i64 = row.get(6)?;
Ok(WorldBuilding {
project_id,
description,
era,
locations,
rules,
extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()),
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms)
.unwrap_or_else(Utc::now),
})
},
);
match result {
Ok(wb) => Ok(Some(wb)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("获取世界观失败: {}", e)),
}
}
/// 更新或创建世界观
pub fn upsert_world_building(
&self,
project_id: &str,
updates: WorldBuildingUpdateRequest,
) -> Result<WorldBuilding, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let now = Utc::now();
let extra_json = updates
.extra
.as_ref()
.map(|e| serde_json::to_string(e).unwrap_or_default());
conn.execute(
"INSERT INTO world_building (project_id, description, era, locations, rules, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
description = COALESCE(excluded.description, description),
era = COALESCE(excluded.era, era),
locations = COALESCE(excluded.locations, locations),
rules = COALESCE(excluded.rules, rules),
extra_json = COALESCE(excluded.extra_json, extra_json),
updated_at = excluded.updated_at",
params![
project_id,
updates.description.unwrap_or_default(),
updates.era,
updates.locations,
updates.rules,
extra_json,
now.timestamp_millis(),
],
)
.map_err(|e| format!("更新世界观失败: {}", e))?;
drop(conn);
self.get_world_building(project_id)?
.ok_or_else(|| "世界观不存在".to_string())
}
// ==================== 风格指南管理 ====================
/// 获取风格指南
pub fn get_style_guide(&self, project_id: &str) -> Result<Option<StyleGuide>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT project_id, style, tone, forbidden_words_json, preferred_words_json, examples, extra_json, updated_at
FROM style_guides WHERE project_id = ?",
params![project_id],
|row| {
let project_id: String = row.get(0)?;
let style: String = row.get(1)?;
let tone: Option<String> = row.get(2)?;
let forbidden_words_json: String = row.get(3)?;
let preferred_words_json: String = row.get(4)?;
let examples: Option<String> = row.get(5)?;
let extra_json: Option<String> = row.get(6)?;
let updated_at_ms: i64 = row.get(7)?;
Ok(StyleGuide {
project_id,
style,
tone,
forbidden_words: serde_json::from_str(&forbidden_words_json).unwrap_or_default(),
preferred_words: serde_json::from_str(&preferred_words_json).unwrap_or_default(),
examples,
extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()),
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms).unwrap_or_else(Utc::now),
})
},
);
match result {
Ok(sg) => Ok(Some(sg)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("获取风格指南失败: {}", e)),
}
}
/// 更新或创建风格指南
pub fn upsert_style_guide(
&self,
project_id: &str,
updates: StyleGuideUpdateRequest,
) -> Result<StyleGuide, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let now = Utc::now();
let forbidden_words_json = updates
.forbidden_words
.as_ref()
.map(|w| serde_json::to_string(w).unwrap_or_default())
.unwrap_or_else(|| "[]".to_string());
let preferred_words_json = updates
.preferred_words
.as_ref()
.map(|w| serde_json::to_string(w).unwrap_or_default())
.unwrap_or_else(|| "[]".to_string());
let extra_json = updates
.extra
.as_ref()
.map(|e| serde_json::to_string(e).unwrap_or_default());
conn.execute(
"INSERT INTO style_guides (project_id, style, tone, forbidden_words_json, preferred_words_json, examples, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
style = COALESCE(excluded.style, style),
tone = COALESCE(excluded.tone, tone),
forbidden_words_json = excluded.forbidden_words_json,
preferred_words_json = excluded.preferred_words_json,
examples = COALESCE(excluded.examples, examples),
extra_json = COALESCE(excluded.extra_json, extra_json),
updated_at = excluded.updated_at",
params![
project_id,
updates.style.unwrap_or_default(),
updates.tone,
forbidden_words_json,
preferred_words_json,
updates.examples,
extra_json,
now.timestamp_millis(),
],
)
.map_err(|e| format!("更新风格指南失败: {}", e))?;
drop(conn);
self.get_style_guide(project_id)?
.ok_or_else(|| "风格指南不存在".to_string())
}
// ==================== 大纲管理 ====================
/// 创建大纲节点
pub fn create_outline_node(
&self,
request: OutlineNodeCreateRequest,
) -> Result<OutlineNode, String> {
let now = Utc::now();
let id = Uuid::new_v4().to_string();
let order = request.order.unwrap_or_else(|| {
self.get_next_outline_order(&request.project_id, request.parent_id.as_deref())
.unwrap_or(0)
});
let node = OutlineNode {
id: id.clone(),
project_id: request.project_id.clone(),
parent_id: request.parent_id,
title: request.title,
content: request.content,
content_id: request.content_id,
order,
expanded: true,
extra: None,
created_at: now,
updated_at: now,
};
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
conn.execute(
"INSERT INTO outline_nodes (id, project_id, parent_id, title, content, content_id, sort_order, expanded, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
&node.id,
&node.project_id,
&node.parent_id,
&node.title,
&node.content,
&node.content_id,
node.order,
node.expanded,
Option::<String>::None,
node.created_at.timestamp_millis(),
node.updated_at.timestamp_millis(),
],
)
.map_err(|e| format!("创建大纲节点失败: {}", e))?;
tracing::info!(
"[Memory] 创建大纲节点: id={}, title={}",
node.id,
node.title
);
Ok(node)
}
/// 获取大纲节点
pub fn get_outline_node(&self, id: &OutlineNodeId) -> Result<Option<OutlineNode>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT id, project_id, parent_id, title, content, content_id, sort_order, expanded, extra_json, created_at, updated_at
FROM outline_nodes WHERE id = ?",
params![id],
|row| Ok(Self::row_to_outline_node(row)?),
);
match result {
Ok(node) => Ok(Some(node)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("获取大纲节点失败: {}", e)),
}
}
/// 列出项目的所有大纲节点
pub fn list_outline_nodes(&self, project_id: &str) -> Result<Vec<OutlineNode>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let mut stmt = conn
.prepare(
"SELECT id, project_id, parent_id, title, content, content_id, sort_order, expanded, extra_json, created_at, updated_at
FROM outline_nodes WHERE project_id = ? ORDER BY sort_order ASC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
let nodes = stmt
.query_map(params![project_id], |row| {
Ok(Self::row_to_outline_node(row)?)
})
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(nodes)
}
/// 更新大纲节点
pub fn update_outline_node(
&self,
id: &OutlineNodeId,
updates: OutlineNodeUpdateRequest,
) -> Result<OutlineNode, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let now = Utc::now().timestamp_millis();
let mut set_clauses = vec!["updated_at = ?"];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
if let Some(ref parent_id) = updates.parent_id {
set_clauses.push("parent_id = ?");
params_vec.push(Box::new(parent_id.clone()));
}
if let Some(ref title) = updates.title {
set_clauses.push("title = ?");
params_vec.push(Box::new(title.clone()));
}
if let Some(ref content) = updates.content {
set_clauses.push("content = ?");
params_vec.push(Box::new(content.clone()));
}
if let Some(ref content_id) = updates.content_id {
set_clauses.push("content_id = ?");
params_vec.push(Box::new(content_id.clone()));
}
if let Some(order) = updates.order {
set_clauses.push("sort_order = ?");
params_vec.push(Box::new(order));
}
if let Some(expanded) = updates.expanded {
set_clauses.push("expanded = ?");
params_vec.push(Box::new(expanded));
}
if let Some(ref extra) = updates.extra {
set_clauses.push("extra_json = ?");
params_vec.push(Box::new(serde_json::to_string(extra).unwrap_or_default()));
}
params_vec.push(Box::new(id.clone()));
let sql = format!(
"UPDATE outline_nodes SET {} WHERE id = ?",
set_clauses.join(", ")
);
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice())
.map_err(|e| format!("更新大纲节点失败: {}", e))?;
drop(conn);
self.get_outline_node(id)?
.ok_or_else(|| "大纲节点不存在".to_string())
}
/// 删除大纲节点
pub fn delete_outline_node(&self, id: &OutlineNodeId) -> Result<bool, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let affected = conn
.execute("DELETE FROM outline_nodes WHERE id = ?", params![id])
.map_err(|e| format!("删除大纲节点失败: {}", e))?;
if affected > 0 {
tracing::info!("[Memory] 删除大纲节点: id={}", id);
}
Ok(affected > 0)
}
fn get_next_outline_order(
&self,
project_id: &str,
parent_id: Option<&str>,
) -> Result<i32, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result: Result<i32, _> = match parent_id {
Some(pid) => conn.query_row(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM outline_nodes WHERE project_id = ? AND parent_id = ?",
params![project_id, pid],
|row| row.get(0),
),
None => conn.query_row(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM outline_nodes WHERE project_id = ? AND parent_id IS NULL",
params![project_id],
|row| row.get(0),
),
};
result.map_err(|e| format!("获取排序顺序失败: {}", e))
}
fn row_to_outline_node(row: &rusqlite::Row) -> Result<OutlineNode, rusqlite::Error> {
let id: String = row.get(0)?;
let project_id: String = row.get(1)?;
let parent_id: Option<String> = row.get(2)?;
let title: String = row.get(3)?;
let content: Option<String> = row.get(4)?;
let content_id: Option<String> = row.get(5)?;
let order: i32 = row.get(6)?;
let expanded: bool = row.get(7)?;
let extra_json: Option<String> = row.get(8)?;
let created_at_ms: i64 = row.get(9)?;
let updated_at_ms: i64 = row.get(10)?;
Ok(OutlineNode {
id,
project_id,
parent_id,
title,
content,
content_id,
order,
expanded,
extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()),
created_at: chrono::DateTime::from_timestamp_millis(created_at_ms)
.unwrap_or_else(Utc::now),
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms)
.unwrap_or_else(Utc::now),
})
}
// ==================== 聚合查询 ====================
/// 获取项目的完整记忆
pub fn get_project_memory(&self, project_id: &str) -> Result<ProjectMemory, String> {
let characters = self.list_characters(project_id)?;
let world_building = self.get_world_building(project_id)?;
let style_guide = self.get_style_guide(project_id)?;
let outline = self.list_outline_nodes(project_id)?;
Ok(ProjectMemory {
characters,
world_building,
style_guide,
outline,
})
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Memory 模块
//!
//! 提供项目记忆系统管理功能(角色、世界观、风格指南、大纲)。
pub mod manager;
pub mod types;
pub use manager::MemoryManager;
pub use types::*;
+335
View File
@@ -0,0 +1,335 @@
//! Memory 类型定义
//!
//! 定义项目记忆系统相关的数据结构(角色、世界观、大纲等)。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 角色 ID
pub type CharacterId = String;
/// 大纲节点 ID
pub type OutlineNodeId = String;
/// 角色设定
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character {
/// 唯一标识
pub id: CharacterId,
/// 所属项目 ID
pub project_id: String,
/// 角色名称
pub name: String,
/// 角色别名/昵称
#[serde(default)]
pub aliases: Vec<String>,
/// 角色描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 角色性格
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<String>,
/// 角色背景
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// 角色外貌
#[serde(skip_serializing_if = "Option::is_none")]
pub appearance: Option<String>,
/// 角色关系(与其他角色的关系)
#[serde(default)]
pub relationships: Vec<CharacterRelationship>,
/// 角色头像 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
/// 是否为主要角色
#[serde(default)]
pub is_main: bool,
/// 排序顺序
#[serde(default)]
pub order: i32,
/// 额外属性(JSON)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
/// 角色关系
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterRelationship {
/// 目标角色 ID
pub target_id: CharacterId,
/// 关系类型(如:朋友、敌人、恋人、家人等)
pub relationship_type: String,
/// 关系描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// 角色创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterCreateRequest {
/// 所属项目 ID
pub project_id: String,
/// 角色名称
pub name: String,
/// 角色别名
#[serde(default)]
pub aliases: Vec<String>,
/// 角色描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 角色性格
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<String>,
/// 角色背景
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// 角色外貌
#[serde(skip_serializing_if = "Option::is_none")]
pub appearance: Option<String>,
/// 是否为主要角色
#[serde(default)]
pub is_main: bool,
}
/// 角色更新请求
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CharacterUpdateRequest {
/// 角色名称
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 角色别名
#[serde(skip_serializing_if = "Option::is_none")]
pub aliases: Option<Vec<String>>,
/// 角色描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 角色性格
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<String>,
/// 角色背景
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// 角色外貌
#[serde(skip_serializing_if = "Option::is_none")]
pub appearance: Option<String>,
/// 角色关系
#[serde(skip_serializing_if = "Option::is_none")]
pub relationships: Option<Vec<CharacterRelationship>>,
/// 角色头像 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
/// 是否为主要角色
#[serde(skip_serializing_if = "Option::is_none")]
pub is_main: Option<bool>,
/// 排序顺序
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
/// 额外属性
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
/// 世界观设定
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldBuilding {
/// 所属项目 ID
pub project_id: String,
/// 世界观描述
pub description: String,
/// 时代背景
#[serde(skip_serializing_if = "Option::is_none")]
pub era: Option<String>,
/// 地点设定
#[serde(skip_serializing_if = "Option::is_none")]
pub locations: Option<String>,
/// 规则/设定
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<String>,
/// 额外设定(JSON)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
/// 世界观更新请求
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorldBuildingUpdateRequest {
/// 世界观描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 时代背景
#[serde(skip_serializing_if = "Option::is_none")]
pub era: Option<String>,
/// 地点设定
#[serde(skip_serializing_if = "Option::is_none")]
pub locations: Option<String>,
/// 规则/设定
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<String>,
/// 额外设定
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
/// 风格指南
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleGuide {
/// 所属项目 ID
pub project_id: String,
/// 写作风格描述
pub style: String,
/// 语气/调性
#[serde(skip_serializing_if = "Option::is_none")]
pub tone: Option<String>,
/// 禁用词汇
#[serde(default)]
pub forbidden_words: Vec<String>,
/// 偏好词汇
#[serde(default)]
pub preferred_words: Vec<String>,
/// 示例文本
#[serde(skip_serializing_if = "Option::is_none")]
pub examples: Option<String>,
/// 额外设定(JSON)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
/// 风格指南更新请求
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StyleGuideUpdateRequest {
/// 写作风格描述
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
/// 语气/调性
#[serde(skip_serializing_if = "Option::is_none")]
pub tone: Option<String>,
/// 禁用词汇
#[serde(skip_serializing_if = "Option::is_none")]
pub forbidden_words: Option<Vec<String>>,
/// 偏好词汇
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_words: Option<Vec<String>>,
/// 示例文本
#[serde(skip_serializing_if = "Option::is_none")]
pub examples: Option<String>,
/// 额外设定
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
/// 大纲节点
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutlineNode {
/// 唯一标识
pub id: OutlineNodeId,
/// 所属项目 ID
pub project_id: String,
/// 父节点 ID(null 表示根节点)
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<OutlineNodeId>,
/// 节点标题
pub title: String,
/// 节点内容/描述
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// 关联的内容 ID(如关联到某一集/章节)
#[serde(skip_serializing_if = "Option::is_none")]
pub content_id: Option<String>,
/// 排序顺序
pub order: i32,
/// 是否展开
#[serde(default = "default_true")]
pub expanded: bool,
/// 额外属性(JSON)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
fn default_true() -> bool {
true
}
/// 大纲节点创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutlineNodeCreateRequest {
/// 所属项目 ID
pub project_id: String,
/// 父节点 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<OutlineNodeId>,
/// 节点标题
pub title: String,
/// 节点内容
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// 关联的内容 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub content_id: Option<String>,
/// 排序顺序
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
}
/// 大纲节点更新请求
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OutlineNodeUpdateRequest {
/// 父节点 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Option<OutlineNodeId>>,
/// 节点标题
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// 节点内容
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// 关联的内容 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub content_id: Option<Option<String>>,
/// 排序顺序
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
/// 是否展开
#[serde(skip_serializing_if = "Option::is_none")]
pub expanded: Option<bool>,
/// 额外属性
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
/// 项目记忆(聚合所有记忆数据)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMemory {
/// 角色列表
pub characters: Vec<Character>,
/// 世界观设定
#[serde(skip_serializing_if = "Option::is_none")]
pub world_building: Option<WorldBuilding>,
/// 风格指南
#[serde(skip_serializing_if = "Option::is_none")]
pub style_guide: Option<StyleGuide>,
/// 大纲
pub outline: Vec<OutlineNode>,
}
impl Default for ProjectMemory {
fn default() -> Self {
Self {
characters: Vec::new(),
world_building: None,
style_guide: None,
outline: Vec::new(),
}
}
}
+221 -6
View File
@@ -6,6 +6,7 @@ use super::types::{Workspace, WorkspaceId, WorkspaceSettings, WorkspaceType, Wor
use crate::database::DbConnection;
use chrono::Utc;
use rusqlite::params;
use std::collections::HashSet;
use std::path::PathBuf;
use uuid::Uuid;
@@ -37,6 +38,24 @@ impl WorkspaceManager {
let id = Uuid::new_v4().to_string();
let root_path_str = root_path.to_str().ok_or("无效的路径")?.to_string();
// 根据项目类型设置默认图标
let icon = if workspace_type.is_project_type() {
Some(match &workspace_type {
WorkspaceType::General => "💬".to_string(),
WorkspaceType::SocialMedia => "📱".to_string(),
WorkspaceType::Poster => "🖼️".to_string(),
WorkspaceType::Music => "🎵".to_string(),
WorkspaceType::Knowledge => "🔍".to_string(),
WorkspaceType::Planning => "📅".to_string(),
WorkspaceType::Document => "📄".to_string(),
WorkspaceType::Video => "🎬".to_string(),
WorkspaceType::Novel => "📖".to_string(),
_ => "📁".to_string(),
})
} else {
None
};
let workspace = Workspace {
id: id.clone(),
name,
@@ -46,6 +65,12 @@ impl WorkspaceManager {
created_at: now,
updated_at: now,
settings: WorkspaceSettings::default(),
icon,
color: None,
is_favorite: false,
is_archived: false,
tags: Vec::new(),
stats: None,
};
let conn = self
@@ -53,6 +78,8 @@ impl WorkspaceManager {
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
Self::ensure_workspace_columns(&conn)?;
// 检查路径是否已存在
let exists: bool = conn
.query_row(
@@ -68,10 +95,11 @@ impl WorkspaceManager {
let settings_json =
serde_json::to_string(&workspace.settings).map_err(|e| e.to_string())?;
let tags_json = serde_json::to_string(&workspace.tags).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, icon, color, is_favorite, is_archived, tags_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
&workspace.id,
&workspace.name,
@@ -79,6 +107,11 @@ impl WorkspaceManager {
&root_path_str,
workspace.is_default,
&settings_json,
&workspace.icon,
&workspace.color,
workspace.is_favorite,
workspace.is_archived,
&tags_json,
workspace.created_at.timestamp_millis(),
workspace.updated_at.timestamp_millis(),
],
@@ -95,6 +128,46 @@ impl WorkspaceManager {
Ok(workspace)
}
/// 确保 workspaces 表包含项目管理相关字段
fn ensure_workspace_columns(conn: &rusqlite::Connection) -> Result<(), String> {
let mut stmt = conn
.prepare("PRAGMA table_info(workspaces)")
.map_err(|e| format!("读取 workspaces 表结构失败: {}", e))?;
let columns = stmt
.query_map([], |row| {
let column_name: String = row.get(1)?;
Ok(column_name)
})
.map_err(|e| format!("读取 workspaces 表结构失败: {}", e))?
.collect::<Result<HashSet<_>, _>>()
.map_err(|e| format!("解析 workspaces 表结构失败: {}", e))?;
let add_column = |sql: &str| -> Result<(), String> {
conn.execute(sql, [])
.map_err(|e| format!("更新 workspaces 表结构失败: {}", e))?;
Ok(())
};
if !columns.contains("icon") {
add_column("ALTER TABLE workspaces ADD COLUMN icon TEXT")?;
}
if !columns.contains("color") {
add_column("ALTER TABLE workspaces ADD COLUMN color TEXT")?;
}
if !columns.contains("is_favorite") {
add_column("ALTER TABLE workspaces ADD COLUMN is_favorite INTEGER DEFAULT 0")?;
}
if !columns.contains("is_archived") {
add_column("ALTER TABLE workspaces ADD COLUMN is_archived INTEGER DEFAULT 0")?;
}
if !columns.contains("tags_json") {
add_column("ALTER TABLE workspaces ADD COLUMN tags_json TEXT DEFAULT '[]'")?;
}
Ok(())
}
/// 获取 workspace
pub fn get(&self, id: &WorkspaceId) -> Result<Option<Workspace>, String> {
let conn = self
@@ -103,7 +176,7 @@ impl WorkspaceManager {
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces WHERE id = ?",
params![id],
|row| {
@@ -128,7 +201,7 @@ impl WorkspaceManager {
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces WHERE root_path = ?",
params![root_path_str],
|row| {
@@ -152,7 +225,7 @@ impl WorkspaceManager {
let mut stmt = conn
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces ORDER BY updated_at DESC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
@@ -166,6 +239,108 @@ impl WorkspaceManager {
Ok(workspaces)
}
/// 列出所有项目类型的 workspace
pub fn list_projects(&self) -> Result<Vec<Workspace>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let mut stmt = conn
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE workspace_type IN ('drama', 'novel', 'social', 'document', 'general')
ORDER BY updated_at DESC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
let workspaces = stmt
.query_map([], |row| Ok(Self::row_to_workspace(row)?))
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(workspaces)
}
/// 列出指定类型的项目
pub fn list_by_type(&self, workspace_type: &WorkspaceType) -> Result<Vec<Workspace>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let mut stmt = conn
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE workspace_type = ?
ORDER BY updated_at DESC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
let workspaces = stmt
.query_map(params![workspace_type.as_str()], |row| {
Ok(Self::row_to_workspace(row)?)
})
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(workspaces)
}
/// 列出收藏的项目
pub fn list_favorites(&self) -> Result<Vec<Workspace>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let mut stmt = conn
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE is_favorite = 1 AND is_archived = 0
ORDER BY updated_at DESC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
let workspaces = stmt
.query_map([], |row| Ok(Self::row_to_workspace(row)?))
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(workspaces)
}
/// 列出归档的项目
pub fn list_archived(&self) -> Result<Vec<Workspace>, String> {
let conn = self
.db
.lock()
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let mut stmt = conn
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE is_archived = 1
ORDER BY updated_at DESC",
)
.map_err(|e| format!("准备查询失败: {}", e))?;
let workspaces = stmt
.query_map([], |row| Ok(Self::row_to_workspace(row)?))
.map_err(|e| format!("查询失败: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {}", e))?;
Ok(workspaces)
}
/// 更新 workspace
pub fn update(&self, id: &WorkspaceId, updates: WorkspaceUpdate) -> Result<Workspace, String> {
let conn = self
@@ -189,6 +364,32 @@ impl WorkspaceManager {
params_vec.push(Box::new(settings_json));
}
if let Some(ref icon) = updates.icon {
set_clauses.push("icon = ?");
params_vec.push(Box::new(icon.clone()));
}
if let Some(ref color) = updates.color {
set_clauses.push("color = ?");
params_vec.push(Box::new(color.clone()));
}
if let Some(is_favorite) = updates.is_favorite {
set_clauses.push("is_favorite = ?");
params_vec.push(Box::new(is_favorite));
}
if let Some(is_archived) = updates.is_archived {
set_clauses.push("is_archived = ?");
params_vec.push(Box::new(is_archived));
}
if let Some(ref tags) = updates.tags {
let tags_json = serde_json::to_string(tags).map_err(|e| e.to_string())?;
set_clauses.push("tags_json = ?");
params_vec.push(Box::new(tags_json));
}
params_vec.push(Box::new(id.clone()));
let sql = format!(
@@ -260,7 +461,7 @@ impl WorkspaceManager {
.map_err(|e| format!("数据库锁定失败: {}", e))?;
let result = conn.query_row(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces WHERE is_default = 1",
[],
|row| {
@@ -285,8 +486,16 @@ impl WorkspaceManager {
let settings_json: String = row.get(5)?;
let created_at_ms: i64 = row.get(6)?;
let updated_at_ms: i64 = row.get(7)?;
let icon: Option<String> = row.get(8)?;
let color: Option<String> = row.get(9)?;
let is_favorite: bool = row.get::<_, Option<bool>>(10)?.unwrap_or(false);
let is_archived: bool = row.get::<_, Option<bool>>(11)?.unwrap_or(false);
let tags_json: Option<String> = row.get(12)?;
let settings: WorkspaceSettings = serde_json::from_str(&settings_json).unwrap_or_default();
let tags: Vec<String> = tags_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Ok(Workspace {
id,
@@ -299,6 +508,12 @@ impl WorkspaceManager {
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms)
.unwrap_or_else(Utc::now),
settings,
icon,
color,
is_favorite,
is_archived,
tags,
stats: None, // 统计信息需要单独查询
})
}
}
+263 -1
View File
@@ -11,13 +11,31 @@ pub type WorkspaceId = String;
/// Workspace 类型
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
#[serde(rename_all = "kebab-case")]
pub enum WorkspaceType {
/// 持久化 workspace
#[default]
Persistent,
/// 临时 workspace(自动清理)
Temporary,
/// 通用对话
General,
/// 社媒内容
SocialMedia,
/// 图文海报
Poster,
/// 歌词曲谱
Music,
/// 知识探索
Knowledge,
/// 计划规划
Planning,
/// 办公文档
Document,
/// 短视频
Video,
/// 小说创作
Novel,
}
impl WorkspaceType {
@@ -25,15 +43,52 @@ impl WorkspaceType {
match self {
WorkspaceType::Persistent => "persistent",
WorkspaceType::Temporary => "temporary",
WorkspaceType::General => "general",
WorkspaceType::SocialMedia => "social-media",
WorkspaceType::Poster => "poster",
WorkspaceType::Music => "music",
WorkspaceType::Knowledge => "knowledge",
WorkspaceType::Planning => "planning",
WorkspaceType::Document => "document",
WorkspaceType::Video => "video",
WorkspaceType::Novel => "novel",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"temporary" => WorkspaceType::Temporary,
"general" => WorkspaceType::General,
"social-media" => WorkspaceType::SocialMedia,
"poster" => WorkspaceType::Poster,
"music" => WorkspaceType::Music,
"knowledge" => WorkspaceType::Knowledge,
"planning" => WorkspaceType::Planning,
"document" => WorkspaceType::Document,
"video" => WorkspaceType::Video,
"novel" => WorkspaceType::Novel,
// 旧类型兼容映射
"drama" => WorkspaceType::Video,
"social" => WorkspaceType::SocialMedia,
_ => WorkspaceType::Persistent,
}
}
/// 判断是否为项目类型
pub fn is_project_type(&self) -> bool {
matches!(
self,
WorkspaceType::General
| WorkspaceType::SocialMedia
| WorkspaceType::Poster
| WorkspaceType::Music
| WorkspaceType::Knowledge
| WorkspaceType::Planning
| WorkspaceType::Document
| WorkspaceType::Video
| WorkspaceType::Novel
)
}
}
/// Workspace 级别设置
@@ -50,6 +105,23 @@ pub struct WorkspaceSettings {
pub auto_compact: bool,
}
/// 项目统计信息
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectStats {
/// 内容数量
#[serde(default)]
pub content_count: i64,
/// 总字数
#[serde(default)]
pub total_words: i64,
/// 已完成数量
#[serde(default)]
pub completed_count: i64,
/// 最后访问时间
#[serde(skip_serializing_if = "Option::is_none")]
pub last_accessed: Option<DateTime<Utc>>,
}
/// Workspace 元数据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
@@ -69,6 +141,24 @@ pub struct Workspace {
pub updated_at: DateTime<Utc>,
/// Workspace 级别设置
pub settings: WorkspaceSettings,
/// 项目图标(emoji 或图标名称)
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 项目颜色(hex 格式)
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
/// 是否收藏
#[serde(default)]
pub is_favorite: bool,
/// 是否归档
#[serde(default)]
pub is_archived: bool,
/// 标签列表
#[serde(default)]
pub tags: Vec<String>,
/// 项目统计信息
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<ProjectStats>,
}
/// Workspace 更新请求
@@ -80,6 +170,21 @@ pub struct WorkspaceUpdate {
/// 新设置
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<WorkspaceSettings>,
/// 项目图标
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 项目颜色
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
/// 是否收藏
#[serde(skip_serializing_if = "Option::is_none")]
pub is_favorite: Option<bool>,
/// 是否归档
#[serde(skip_serializing_if = "Option::is_none")]
pub is_archived: Option<bool>,
/// 标签列表
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
/// Workspace 创建请求
@@ -93,3 +198,160 @@ pub struct WorkspaceCreateRequest {
#[serde(default)]
pub workspace_type: WorkspaceType,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_workspace_type_as_str() {
assert_eq!(WorkspaceType::Persistent.as_str(), "persistent");
assert_eq!(WorkspaceType::Temporary.as_str(), "temporary");
assert_eq!(WorkspaceType::General.as_str(), "general");
assert_eq!(WorkspaceType::SocialMedia.as_str(), "social-media");
assert_eq!(WorkspaceType::Poster.as_str(), "poster");
assert_eq!(WorkspaceType::Music.as_str(), "music");
assert_eq!(WorkspaceType::Knowledge.as_str(), "knowledge");
assert_eq!(WorkspaceType::Planning.as_str(), "planning");
assert_eq!(WorkspaceType::Document.as_str(), "document");
assert_eq!(WorkspaceType::Video.as_str(), "video");
assert_eq!(WorkspaceType::Novel.as_str(), "novel");
}
#[test]
fn test_workspace_type_from_str() {
assert_eq!(
WorkspaceType::from_str("persistent"),
WorkspaceType::Persistent
);
assert_eq!(
WorkspaceType::from_str("temporary"),
WorkspaceType::Temporary
);
assert_eq!(WorkspaceType::from_str("general"), WorkspaceType::General);
assert_eq!(
WorkspaceType::from_str("social-media"),
WorkspaceType::SocialMedia
);
assert_eq!(WorkspaceType::from_str("poster"), WorkspaceType::Poster);
assert_eq!(WorkspaceType::from_str("music"), WorkspaceType::Music);
assert_eq!(
WorkspaceType::from_str("knowledge"),
WorkspaceType::Knowledge
);
assert_eq!(WorkspaceType::from_str("planning"), WorkspaceType::Planning);
assert_eq!(WorkspaceType::from_str("document"), WorkspaceType::Document);
assert_eq!(WorkspaceType::from_str("video"), WorkspaceType::Video);
assert_eq!(WorkspaceType::from_str("novel"), WorkspaceType::Novel);
}
#[test]
fn test_legacy_type_migration() {
// 旧类型应该正确映射到新类型
assert_eq!(WorkspaceType::from_str("drama"), WorkspaceType::Video);
assert_eq!(
WorkspaceType::from_str("social"),
WorkspaceType::SocialMedia
);
}
#[test]
fn test_unknown_type_defaults_to_persistent() {
assert_eq!(
WorkspaceType::from_str("unknown"),
WorkspaceType::Persistent
);
assert_eq!(WorkspaceType::from_str(""), WorkspaceType::Persistent);
assert_eq!(
WorkspaceType::from_str("invalid"),
WorkspaceType::Persistent
);
}
#[test]
fn test_is_project_type() {
// 用户级类型应该返回 true
assert!(WorkspaceType::General.is_project_type());
assert!(WorkspaceType::SocialMedia.is_project_type());
assert!(WorkspaceType::Poster.is_project_type());
assert!(WorkspaceType::Music.is_project_type());
assert!(WorkspaceType::Knowledge.is_project_type());
assert!(WorkspaceType::Planning.is_project_type());
assert!(WorkspaceType::Document.is_project_type());
assert!(WorkspaceType::Video.is_project_type());
assert!(WorkspaceType::Novel.is_project_type());
// 系统级类型应该返回 false
assert!(!WorkspaceType::Persistent.is_project_type());
assert!(!WorkspaceType::Temporary.is_project_type());
}
#[test]
fn test_serde_serialization() {
// 测试序列化为 kebab-case
let json = serde_json::to_string(&WorkspaceType::SocialMedia).unwrap();
assert_eq!(json, "\"social-media\"");
let json = serde_json::to_string(&WorkspaceType::Video).unwrap();
assert_eq!(json, "\"video\"");
let json = serde_json::to_string(&WorkspaceType::Persistent).unwrap();
assert_eq!(json, "\"persistent\"");
}
#[test]
fn test_serde_deserialization() {
// 测试从 kebab-case 反序列化
let wt: WorkspaceType = serde_json::from_str("\"social-media\"").unwrap();
assert_eq!(wt, WorkspaceType::SocialMedia);
let wt: WorkspaceType = serde_json::from_str("\"video\"").unwrap();
assert_eq!(wt, WorkspaceType::Video);
let wt: WorkspaceType = serde_json::from_str("\"persistent\"").unwrap();
assert_eq!(wt, WorkspaceType::Persistent);
}
#[test]
fn test_roundtrip_all_types() {
let types = vec![
WorkspaceType::Persistent,
WorkspaceType::Temporary,
WorkspaceType::General,
WorkspaceType::SocialMedia,
WorkspaceType::Poster,
WorkspaceType::Music,
WorkspaceType::Knowledge,
WorkspaceType::Planning,
WorkspaceType::Document,
WorkspaceType::Video,
WorkspaceType::Novel,
];
for wt in types {
let s = wt.as_str();
let parsed = WorkspaceType::from_str(s);
assert_eq!(wt, parsed, "Roundtrip failed for {:?}", wt);
}
}
#[test]
fn test_default_workspace_type() {
let default_type = WorkspaceType::default();
assert_eq!(default_type, WorkspaceType::Persistent);
}
#[test]
fn test_workspace_type_clone() {
let original = WorkspaceType::Video;
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn test_workspace_type_debug() {
let wt = WorkspaceType::SocialMedia;
let debug_str = format!("{:?}", wt);
assert_eq!(debug_str, "SocialMedia");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.50.0",
"version": "0.51.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+137 -3
View File
@@ -20,6 +20,9 @@ import { ToolsPage } from "./components/tools/ToolsPage";
import { AgentChatPage } from "./components/agent";
import { PluginsPage } from "./components/plugins/PluginsPage";
import { ImageGenPage } from "./components/image-gen";
import { ProjectsPage } from "./components/projects";
import { CreateProjectDialog } from "./components/projects/CreateProjectDialog";
import { ProjectType } from "./lib/api/project";
import {
TerminalWorkspace,
@@ -36,7 +39,10 @@ import { useRelayRegistry } from "./hooks/useRelayRegistry";
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
import { SoundProvider } from "./contexts/SoundProvider";
import { ComponentDebugOverlay } from "./components/dev";
import { Page } from "./types/page";
import { Page, PageParams, AgentPageParams } from "./types/page";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, createContent } from "./lib/api/project";
import { toast } from "sonner";
const AppContainer = styled.div`
display: flex;
@@ -77,8 +83,114 @@ const FullscreenWrapper = styled.div<{ $isActive: boolean }>`
function AppContent() {
const [showSplash, setShowSplash] = useState(true);
const [currentPage, setCurrentPage] = useState<Page>("agent");
const [pageParams, setPageParams] = useState<PageParams>({});
const { needsOnboarding, completeOnboarding } = useOnboardingState();
// 推荐标签引导创建项目相关状态
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [pendingRecommendation, setPendingRecommendation] = useState<{
shortLabel: string;
fullPrompt: string;
projectType: ProjectType;
projectName: string;
} | null>(null);
// 带参数的页面导航
const handleNavigate = useCallback((page: Page, params?: PageParams) => {
setCurrentPage(page);
if (params) {
setPageParams(params);
} else {
setPageParams({});
}
}, []);
// 推荐标签点击处理 - 打开创建项目对话框
const _handleRequestRecommendation = useCallback(
(shortLabel: string, fullPrompt: string, currentTheme: string) => {
// 主题标签映射
const themeLabels: Record<string, string> = {
"social-media": "社媒",
poster: "海报",
music: "音乐",
knowledge: "知识",
planning: "计划",
novel: "小说",
document: "文档",
video: "视频",
general: "对话",
};
const prefix = themeLabels[currentTheme] || "项目";
const projectName = `${prefix}:${shortLabel}`;
setPendingRecommendation({
shortLabel,
fullPrompt,
projectType: currentTheme as ProjectType,
projectName,
});
setProjectDialogOpen(true);
},
[],
);
// 创建项目并创建初始内容
const handleCreateProjectFromRecommendation = async (
name: string,
type: ProjectType,
) => {
// 选择项目目录
const selectedPath = await open({
directory: true,
title: "选择项目目录",
});
if (!selectedPath) {
throw new Error("用户取消选择目录");
}
const projectPath = Array.isArray(selectedPath)
? selectedPath.length === 1
? selectedPath[0]
: null
: selectedPath;
if (!projectPath) {
throw new Error("请选择单个项目目录");
}
// 创建项目
const project = await createProject({
name,
rootPath: projectPath,
workspaceType: type,
});
// 如果有待处理的推荐内容,创建初始 Content
if (pendingRecommendation) {
const content = await createContent({
project_id: project.id,
title: name,
body: pendingRecommendation.fullPrompt,
});
// 导航到 Agent 页面
handleNavigate("agent", {
projectId: project.id,
contentId: content.id,
});
// 清除待处理的推荐
setPendingRecommendation(null);
} else {
// 没有初始内容,直接导航到项目页面
handleNavigate("projects");
}
toast.success("项目创建成功");
};
// Deep Link 处理 Hook
// _Requirements: 5.2_
const {
@@ -168,9 +280,17 @@ function AppContent() {
flexDirection: "column",
}}
>
<AgentChatPage />
<AgentChatPage
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
/>
</div>
{/* 项目页面 */}
<PageWrapper $isActive={currentPage === "projects"}>
<ProjectsPage onNavigate={handleNavigate} />
</PageWrapper>
{/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */}
<div
style={{
@@ -243,7 +363,7 @@ function AppContent() {
<SoundProvider>
<ComponentDebugProvider>
<AppContainer>
<AppSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
<AppSidebar currentPage={currentPage} onNavigate={handleNavigate} />
<MainContent>{renderAllPages()}</MainContent>
{/* ProxyCast Connect 确认弹窗 */}
{/* _Requirements: 5.2_ */}
@@ -259,6 +379,20 @@ function AppContent() {
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
{/* 创建项目对话框 - 用于推荐标签引导创建 */}
<CreateProjectDialog
open={projectDialogOpen}
onOpenChange={(open) => {
setProjectDialogOpen(open);
if (!open) {
// 用户取消,清除待处理的推荐
setPendingRecommendation(null);
}
}}
onSubmit={handleCreateProjectFromRecommendation}
defaultType={pendingRecommendation?.projectType}
defaultName={pendingRecommendation?.projectName}
/>
{/* 组件视图调试覆盖层 */}
<ComponentDebugOverlay />
</AppContainer>
+4 -2
View File
@@ -24,15 +24,16 @@ import {
Activity,
Terminal,
Image,
FolderKanban,
LucideIcon,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
import { Page } from "@/types/page";
import { Page, PageParams } from "@/types/page";
interface AppSidebarProps {
currentPage: Page;
onNavigate: (page: Page) => void;
onNavigate: (page: Page, params?: PageParams) => void;
}
const Container = styled.div`
@@ -122,6 +123,7 @@ const IconButton = styled.button<{ $active?: boolean }>`
const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [
{ id: "agent", label: "AI Agent", icon: Bot },
{ id: "projects", label: "项目", icon: FolderKanban },
{ id: "image-gen", label: "图片生成", icon: Image },
{ id: "api-server", label: "API Server", icon: Globe },
{ id: "provider-pool", label: "凭证池", icon: Database },
@@ -0,0 +1,145 @@
/**
* @file Artifact 占位符组件
* @description 在聊天消息中显示代码块的占位符卡片,点击可在画布中查看
* @module components/agent/chat/components/ArtifactPlaceholder
*/
import React, { memo } from "react";
import { FileCode, ExternalLink } from "lucide-react";
import styled from "styled-components";
const PlaceholderCard = styled.div`
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin: 8px 0;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: hsl(var(--muted));
border-color: hsl(var(--primary) / 0.5);
}
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: hsl(var(--primary) / 0.1);
border-radius: 8px;
color: hsl(var(--primary));
`;
const ContentWrapper = styled.div`
flex: 1;
min-width: 0;
`;
const Title = styled.div`
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Subtitle = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-top: 2px;
`;
const ActionIcon = styled.div`
color: hsl(var(--muted-foreground));
transition: color 0.2s;
${PlaceholderCard}:hover & {
color: hsl(var(--primary));
}
`;
interface ArtifactPlaceholderProps {
/** 语言类型 */
language: string;
/** 代码行数 */
lineCount?: number;
/** 点击回调 */
onClick?: () => void;
}
/**
* 获取语言显示名称
*/
function getLanguageDisplayName(language: string): string {
const displayNames: Record<string, string> = {
javascript: "JavaScript",
typescript: "TypeScript",
python: "Python",
rust: "Rust",
go: "Go",
java: "Java",
cpp: "C++",
c: "C",
csharp: "C#",
ruby: "Ruby",
php: "PHP",
swift: "Swift",
kotlin: "Kotlin",
html: "HTML",
css: "CSS",
scss: "SCSS",
json: "JSON",
yaml: "YAML",
xml: "XML",
markdown: "Markdown",
sql: "SQL",
shell: "Shell",
bash: "Bash",
tsx: "TypeScript React",
jsx: "JavaScript React",
vue: "Vue",
svelte: "Svelte",
};
const lower = language.toLowerCase();
return displayNames[lower] || language.toUpperCase();
}
/**
* Artifact 占位符组件
* 在聊天消息中显示代码块的简洁卡片
*/
export const ArtifactPlaceholder: React.FC<ArtifactPlaceholderProps> = memo(
({ language, lineCount, onClick }) => {
const displayName = getLanguageDisplayName(language);
return (
<PlaceholderCard onClick={onClick} role="button" tabIndex={0}>
<IconWrapper>
<FileCode size={20} />
</IconWrapper>
<ContentWrapper>
<Title>{displayName} 代码</Title>
<Subtitle>
{lineCount ? `${lineCount} 行` : "点击在画布中查看"}
</Subtitle>
</ContentWrapper>
<ActionIcon>
<ExternalLink size={16} />
</ActionIcon>
</PlaceholderCard>
);
},
);
ArtifactPlaceholder.displayName = "ArtifactPlaceholder";
export default ArtifactPlaceholder;
@@ -234,6 +234,7 @@ interface ChatSidebarProps {
currentTopicId: string | null;
onSwitchTopic: (topicId: string) => void;
onDeleteTopic: (topicId: string) => void;
onRenameTopic?: (topicId: string, newTitle: string) => void;
}
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
@@ -242,11 +243,15 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
currentTopicId,
onSwitchTopic,
onDeleteTopic,
onRenameTopic,
}) => {
const [activeTab, setActiveTab] = useState<"skills" | "topics">("topics");
const [skills, setSkills] = useState<Skill[]>([]);
const [loadingSkills, setLoadingSkills] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const editInputRef = React.useRef<HTMLInputElement>(null);
const loadSkills = async () => {
setLoadingSkills(true);
@@ -308,6 +313,49 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
onDeleteTopic(topicId);
};
// 开始编辑标题
const handleStartEdit = (
e: React.MouseEvent,
topicId: string,
currentTitle: string,
) => {
e.stopPropagation();
setEditingTopicId(topicId);
setEditTitle(currentTitle);
};
// 保存编辑的标题
const handleSaveEdit = () => {
if (editingTopicId && editTitle.trim() && onRenameTopic) {
onRenameTopic(editingTopicId, editTitle.trim());
}
setEditingTopicId(null);
setEditTitle("");
};
// 取消编辑
const handleCancelEdit = () => {
setEditingTopicId(null);
setEditTitle("");
};
// 处理输入框键盘事件
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSaveEdit();
} else if (e.key === "Escape") {
handleCancelEdit();
}
};
// 当编辑状态变化时,自动聚焦输入框
React.useEffect(() => {
if (editingTopicId && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingTopicId]);
const installedSkills = skills.filter((s) => s.installed);
const availableSkills = skills.filter((s) => !s.installed);
@@ -350,7 +398,14 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<ListItem
key={topic.id}
$active={topic.id === currentTopicId}
onClick={() => onSwitchTopic(topic.id)}
onClick={() => {
if (editingTopicId !== topic.id) {
onSwitchTopic(topic.id);
}
}}
onDoubleClick={(e) =>
handleStartEdit(e, topic.id, topic.title)
}
>
<MessageSquare
size={15}
@@ -360,13 +415,35 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
: "opacity-50"
}
/>
<span className="title">{topic.title}</span>
<button
className="delete-btn"
onClick={(e) => handleDeleteClick(e, topic.id)}
>
<Trash2 size={14} />
</button>
{editingTopicId === topic.id ? (
<input
ref={editInputRef}
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleSaveEdit}
onClick={(e) => e.stopPropagation()}
style={{
flex: 1,
fontSize: "13px",
padding: "2px 6px",
border: "1px solid hsl(var(--primary))",
borderRadius: "4px",
outline: "none",
}}
/>
) : (
<span className="title">{topic.title}</span>
)}
{editingTopicId !== topic.id && (
<button
className="delete-btn"
onClick={(e) => handleDeleteClick(e, topic.id)}
>
<Trash2 size={14} />
</button>
)}
</ListItem>
))
)}
@@ -346,17 +346,23 @@ interface EmptyStateProps {
activeTheme?: string;
/** 主题变更回调 */
onThemeChange?: (theme: string) => void;
/** 推荐标签点击回调 */
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
}
// Scenarios Configuration
// Scenarios Configuration - 与 ProjectType 统一
const CATEGORIES = [
{
id: "general",
label: "通用对话",
icon: <Globe className="w-4 h-4" />,
},
{ id: "social", label: "社媒内容", icon: <PenTool className="w-4 h-4" /> },
{ id: "image", label: "图文海报", icon: <ImageIcon className="w-4 h-4" /> },
{
id: "social-media",
label: "社媒内容",
icon: <PenTool className="w-4 h-4" />,
},
{ id: "poster", label: "图文海报", icon: <ImageIcon className="w-4 h-4" /> },
{ id: "music", label: "歌词曲谱", icon: <Music className="w-4 h-4" /> },
{
id: "knowledge",
@@ -368,12 +374,158 @@ const CATEGORIES = [
label: "计划规划",
icon: <CalendarRange className="w-4 h-4" />,
},
{ id: "office", label: "办公文档", icon: <FileText className="w-4 h-4" /> },
{ id: "document", label: "办公文档", icon: <FileText className="w-4 h-4" /> },
{ id: "video", label: "短视频", icon: <Video className="w-4 h-4" /> },
{ id: "novel", label: "小说创作", icon: <PenTool className="w-4 h-4" /> },
];
// 需要显示创作模式选择器的主题
const CREATION_THEMES = ["social", "image", "office", "video", "music"];
const CREATION_THEMES = [
"social-media",
"poster",
"document",
"video",
"music",
"novel",
];
/**
* 推荐内容配置
* 格式: [简化标题, 完整 Prompt]
* 简化标题用于显示,完整 Prompt 用于点击发送
*/
const RECOMMENDATIONS: Record<string, [string, string][]> = {
"social-media": [
[
"爆款标题生成",
"帮我为'春季护肤routine'写10个小红书爆款标题,要求:数字开头、制造悬念、引发共鸣",
],
[
"小红书探店文案",
"写一篇小红书探店文案:周末在杭州发现一家宝藏咖啡店,工业风装修+拉花拿铁,适合拍照出片",
],
[
"公众号排版",
"帮我把这段话排版成公众号风格:每段不超过150字,加入小标题和emoji,重点内容加粗",
],
[
"评论区回复",
"用户评论'这个产品真的好用吗?还是广告?',帮我写一条真诚、有说服力的回复",
],
],
poster: [
[
"海报设计",
"设计一张夏日音乐节海报:主色调渐变蓝紫,中央是剪影吉他和声波元素,底部大标题'夏日音浪'",
],
[
"插画生成",
"生成一幅温馨的卧室插画:暖色调,落地窗透进阳光,书桌上有绿植和笔记本,治愈系风格",
],
[
"UI 界面",
"设计一个健身APP首页:深色模式,顶部显示今日步数,中间是环形进度条,底部四个功能入口",
],
[
"Logo 设计",
"设计一家名为'绿野'的有机食品品牌Logo:简约绿色叶子轮廓,可单独使用,适合多种尺寸",
],
[
"摄影修图",
"人像照片调色建议:肤色通透,背景偏暖,整体日系清新风格,降低对比度提升亮度",
],
],
knowledge: [
[
"解释量子计算",
"用通俗易懂的方式解释量子计算是什么,类比成生活中的例子,适合非理科背景的人理解",
],
[
"总结这篇论文",
"[粘贴论文链接或内容后] 帮我总结这篇论文的核心观点、研究方法和主要结论,输出500字以内的摘要",
],
[
"如何制定OKR",
"详细介绍OKR(目标与关键结果)制定方法,包括设定原则、常见误区和实际案例,适合团队管理者",
],
[
"分析行业趋势",
"分析2024年AI行业发展趋势,从技术突破、商业化进程、监管政策三个维度展开",
],
],
planning: [
[
"日本旅行计划",
"帮我制定一个7天日本关西旅行计划:大阪进京都出,包含主要景点、美食推荐、交通路线和预算估算",
],
[
"年度职业规划",
"制定一名前端开发工程师的2024年职业规划:技能提升、项目经验、人脉积累、求职目标四个维度",
],
[
"婚礼流程表",
"制定一场户外草坪婚礼的流程表:上午10点开始,包含仪式、宴会、互动环节,标注每个环节的时间",
],
[
"健身计划",
"为办公室上班族制定健身计划:每周3次,每次30分钟,无需器械,可在办公室或家中完成",
],
],
music: [
[
"流行情歌",
"创作一首关于'暗恋'的流行情歌:主歌描述图书馆偶遇,副歌表达不敢告白的纠结,温柔的R&B风格",
],
[
"古风歌词",
"创作古风歌词:主题是'江湖离别',意象包括酒、剑、残阳、孤舟,五言句式为主,押韵工整",
],
[
"说唱歌词",
"创作一段励志说唱:主题是'逆风翻盘',讲述从低谷到成功的经历,快节奏,押韵密集,副歌要炸",
],
[
"儿歌创作",
"创作一首儿童安全教育儿歌:主题是'过马路要小心',简单易记,欢快活泼,3-5岁儿童能跟着唱",
],
[
"旋律学习",
"帮我分析《稻香》的旋律特点:调式、和弦进行、节奏型,以及为什么听起来很怀旧温暖",
],
],
novel: [
[
"玄幻小说",
"创作玄幻小说开篇:主角在深山古洞觉醒传承,获得上古剑诀,第一章包含世界观铺垫和悬念设置",
],
[
"都市言情",
"创作都市言情小说开篇:职场新人与高冷上司因工作误会相识,第一章突出女主性格和两人的初次冲突",
],
[
"悬疑推理",
"创作悬疑推理小说开篇:雨夜发生密室杀人案,侦探到达现场发现三条线索,第一章制造悬念和推理伏笔",
],
[
"科幻未来",
"创作科幻小说开篇:2084年人类首次接触外星文明,主角作为语言学家被召唤,第一章描写接触场景和紧张氛围",
],
[
"历史架空",
"创作历史架空小说开篇:三国时期,一个现代人穿越成普通士兵,如何利用现代知识在乱世中生存",
],
],
};
// 主题对应的图标
const THEME_ICONS: Record<string, string> = {
"social-media": "✨",
poster: "🎨",
knowledge: "🔍",
planning: "📅",
music: "🎵",
novel: "📖",
};
export const EmptyState: React.FC<EmptyStateProps> = ({
input,
@@ -383,6 +535,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onCreationModeChange,
activeTheme = "general",
onThemeChange,
onRecommendationClick,
}) => {
// 使用外部传入的 activeTheme,如果有 onThemeChange 则使用受控模式
const handleThemeChange = (theme: string) => {
@@ -403,11 +556,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const handleSend = () => {
if (!input.trim()) return;
let prefix = "";
if (activeTheme === "social") prefix = `[社媒创作: ${platform}] `;
if (activeTheme === "image") prefix = `[图文生成: ${ratio}, ${style}] `;
if (activeTheme === "social-media") prefix = `[社媒创作: ${platform}] `;
if (activeTheme === "poster") prefix = `[图文生成: ${ratio}, ${style}] `;
if (activeTheme === "video") prefix = `[视频脚本] `;
if (activeTheme === "office") prefix = `[办公文档] `;
if (activeTheme === "document") prefix = `[办公文档] `;
if (activeTheme === "music") prefix = `[歌词曲谱] `;
if (activeTheme === "novel") prefix = `[小说创作] `;
if (activeTheme === "knowledge")
prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}] `;
if (activeTheme === "planning") prefix = `[计划规划] `;
@@ -429,16 +583,18 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return "想了解什么?我可以帮你深度搜索、解析概念或总结长文...";
case "planning":
return "告诉我你的目标,无论是旅行计划、职业规划还是活动筹备...";
case "social":
case "social-media":
return "输入主题,帮你创作小红书爆款文案、公众号文章...";
case "image":
case "poster":
return "描述画面主体、风格、构图,生成精美海报或插画...";
case "video":
return "输入视频主题,生成分镜脚本和口播文案...";
case "office":
case "document":
return "输入需求,生成周报、汇报PPT大纲或商务邮件...";
case "music":
return "输入歌曲主题或情感,帮你创作歌词、设计旋律...";
case "novel":
return "输入小说主题或情节,帮你创作章节内容...";
case "general":
return "有什么我可以帮你的?";
default:
@@ -507,7 +663,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<Toolbar>
<ToolLoginLeft>
{activeTheme === "social" && (
{activeTheme === "social-media" && (
<>
<Select
value={platform}
@@ -649,7 +805,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
</Badge>
)}
{activeTheme === "image" && (
{activeTheme === "poster" && (
<>
<Popover>
<PopoverTrigger asChild>
@@ -760,66 +916,26 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
{/* Dynamic Inspiration/Tips based on Tab - Styled nicely */}
<div className="w-full max-w-[800px] flex flex-wrap gap-3 justify-center">
{activeTheme === "social" &&
["爆款标题生成", "小红书文案", "公众号排版", "评论区回复"].map(
(item) => (
<Badge
key={item}
variant="secondary"
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
>
✨ {item}
</Badge>
),
)}
{activeTheme === "image" &&
["海报设计", "插画生成", "UI 界面", "Logo 设计", "摄影修图"].map(
(item) => (
<Badge
key={item}
variant="secondary"
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
>
🎨 {item}
</Badge>
),
)}
{activeTheme === "knowledge" &&
["解释量子计算", "总结这篇论文", "如何制定OKR", "分析行业趋势"].map(
(item) => (
<Badge
key={item}
variant="secondary"
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
>
🔍 {item}
</Badge>
),
)}
{activeTheme === "planning" &&
["日本旅行计划", "年度职业规划", "婚礼流程表", "健身计划"].map(
(item) => (
<Badge
key={item}
variant="secondary"
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
>
📅 {item}
</Badge>
),
)}
{activeTheme === "music" &&
["流行情歌", "古风歌词", "说唱歌词", "儿歌创作", "旋律学习"].map(
(item) => (
<Badge
key={item}
variant="secondary"
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
>
🎵 {item}
</Badge>
),
)}
{RECOMMENDATIONS[activeTheme]?.map(([shortLabel, fullPrompt]) => (
<Badge
key={shortLabel}
variant="secondary"
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
title={fullPrompt}
onClick={() => {
if (onRecommendationClick) {
onRecommendationClick(shortLabel, fullPrompt);
} else {
setInput(fullPrompt);
setTimeout(() => {
onSend(fullPrompt);
}, 100);
}
}}
>
{THEME_ICONS[activeTheme] || "✨"} {shortLabel}
</Badge>
))}
</div>
</ContentWrapper>
</Container>
@@ -11,6 +11,7 @@ import { Copy, Check, Loader2 } from "lucide-react";
import { parseA2UIJson } from "@/components/content-creator/a2ui/parser";
import { A2UIRenderer } from "@/components/content-creator/a2ui/components";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
// Custom styles for markdown content to match Cherry Studio
const MarkdownContainer = styled.div`
@@ -261,10 +262,14 @@ interface MarkdownRendererProps {
content: string;
/** A2UI 表单提交回调 */
onA2UISubmit?: (formData: A2UIFormData) => void;
/** 是否折叠代码块(当画布打开时) */
collapseCodeBlocks?: boolean;
/** 代码块点击回调(用于在画布中显示) */
onCodeBlockClick?: (language: string, code: string) => void;
}
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
({ content, onA2UISubmit }) => {
({ content, onA2UISubmit, collapseCodeBlocks = false, onCodeBlockClick }) => {
const [copied, setCopied] = React.useState<string | null>(null);
const handleCopy = (code: string) => {
@@ -468,7 +473,19 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
return null;
}
// Block code
// 如果启用了代码块折叠,显示占位符卡片
if (collapseCodeBlocks) {
const lineCount = codeContent.split("\n").length;
return (
<ArtifactPlaceholder
language={language}
lineCount={lineCount}
onClick={() => onCodeBlockClick?.(language, codeContent)}
/>
);
}
// Block code - 完整显示
const isCopied = copied === codeContent;
return (
@@ -34,6 +34,10 @@ interface MessageListProps {
onFileClick?: (fileName: string, content: string) => void;
/** 权限确认响应回调 */
onPermissionResponse?: (response: ConfirmResponse) => void;
/** 是否折叠代码块(当画布打开时) */
collapseCodeBlocks?: boolean;
/** 代码块点击回调(用于在画布中显示) */
onCodeBlockClick?: (language: string, code: string) => void;
}
export const MessageList: React.FC<MessageListProps> = ({
@@ -44,6 +48,8 @@ export const MessageList: React.FC<MessageListProps> = ({
onWriteFile,
onFileClick,
onPermissionResponse,
collapseCodeBlocks,
onCodeBlockClick,
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -216,6 +222,8 @@ export const MessageList: React.FC<MessageListProps> = ({
onWriteFile={onWriteFile}
onFileClick={onFileClick}
onPermissionResponse={onPermissionResponse}
collapseCodeBlocks={collapseCodeBlocks}
onCodeBlockClick={onCodeBlockClick}
/>
) : (
<MarkdownRenderer
@@ -77,6 +77,10 @@ interface StreamingTextProps {
charInterval?: number;
/** A2UI 表单提交回调 */
onA2UISubmit?: (formData: A2UIFormData) => void;
/** 是否折叠代码块 */
collapseCodeBlocks?: boolean;
/** 代码块点击回调 */
onCodeBlockClick?: (language: string, code: string) => void;
}
/**
@@ -92,6 +96,8 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
showCursor = true,
charInterval = 12,
onA2UISubmit,
collapseCodeBlocks,
onCodeBlockClick,
}) => {
const [displayText, setDisplayText] = useState("");
const displayIndexRef = useRef(0);
@@ -195,7 +201,12 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
// 如果没有 a2ui 内容,直接使用 MarkdownRenderer
if (!parsedContent.hasA2UI && !parsedContent.hasPending) {
return (
<MarkdownRenderer content={displayText} onA2UISubmit={onA2UISubmit} />
<MarkdownRenderer
content={displayText}
onA2UISubmit={onA2UISubmit}
collapseCodeBlocks={collapseCodeBlocks}
onCodeBlockClick={onCodeBlockClick}
/>
);
}
@@ -243,6 +254,8 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
key={`text-${index}`}
content={textContent}
onA2UISubmit={onA2UISubmit}
collapseCodeBlocks={collapseCodeBlocks}
onCodeBlockClick={onCodeBlockClick}
/>
);
}
@@ -323,6 +336,10 @@ interface StreamingRendererProps {
onFileClick?: (fileName: string, content: string) => void;
/** 权限确认响应回调 */
onPermissionResponse?: (response: ConfirmResponse) => void;
/** 是否折叠代码块(当画布打开时) */
collapseCodeBlocks?: boolean;
/** 代码块点击回调(用于在画布中显示) */
onCodeBlockClick?: (language: string, code: string) => void;
}
/**
@@ -348,6 +365,8 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
onWriteFile,
onFileClick,
onPermissionResponse,
collapseCodeBlocks,
onCodeBlockClick,
}) => {
// 判断是否使用交错显示模式
const useInterleavedMode = contentParts && contentParts.length > 0;
@@ -519,6 +538,8 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
pIndex === partParsed.parts.length - 1
}
onA2UISubmit={onA2UISubmit}
collapseCodeBlocks={collapseCodeBlocks}
onCodeBlockClick={onCodeBlockClick}
/>
);
}
@@ -536,6 +557,8 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
isStreaming={isStreaming && isLastPart}
showCursor={shouldShowCursor && isLastPart}
onA2UISubmit={onA2UISubmit}
collapseCodeBlocks={collapseCodeBlocks}
onCodeBlockClick={onCodeBlockClick}
/>
);
} else if (part.type === "thinking") {
@@ -663,6 +686,8 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
shouldShowCursor && index === parsedContent.parts.length - 1
}
onA2UISubmit={onA2UISubmit}
collapseCodeBlocks={collapseCodeBlocks}
onCodeBlockClick={onCodeBlockClick}
/>
);
}
+469 -2
View File
@@ -11,6 +11,8 @@ import {
listAgentSessions,
deleteAgentSession,
getAgentSessionMessages,
renameAgentSession,
generateAgentTitle,
parseStreamEvent,
sendPermissionResponse,
type AgentProcessStatus,
@@ -27,6 +29,7 @@ import {
getProviderConfig,
type ProviderConfigMap,
} from "../types";
import { useArtifactParser } from "@/lib/artifact/hooks/useArtifactParser";
/** 话题(会话)信息 */
export interface Topic {
@@ -129,6 +132,17 @@ const saveTransient = (key: string, value: unknown) => {
}
};
// 标题手动编辑状态跟踪
const TITLE_EDITED_KEY_PREFIX = "agent_title_manually_edited_";
const isTitleManuallyEdited = (sessionId: string): boolean => {
return loadPersisted(`${TITLE_EDITED_KEY_PREFIX}${sessionId}`, false);
};
const setTitleManuallyEdited = (sessionId: string, edited: boolean) => {
savePersisted(`${TITLE_EDITED_KEY_PREFIX}${sessionId}`, edited);
};
/** useAgentChat 的配置选项 */
interface UseAgentChatOptions {
/** 系统提示词(用于内容创作等场景) */
@@ -172,11 +186,24 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
const [isSending, setIsSending] = useState(false);
// 当前会话的轮数计数器(用于判断何时生成标题)
const [_roundCount, _setRoundCount] = useState(() =>
loadTransient("agent_curr_roundCount", 0),
);
// 用于保存当前流式请求的取消函数
const unlistenRef = useRef<UnlistenFn | null>(null);
// 用于保存当前正在处理的消息 ID
const currentAssistantMsgIdRef = useRef<string | null>(null);
// Artifact 解析器 - 用于流式解析 AI 响应中的 artifact
const {
startParsing: startArtifactParsing,
appendChunk: appendArtifactChunk,
finalizeParsing: finalizeArtifactParsing,
reset: _resetArtifactParser,
} = useArtifactParser();
// 加载动态模型配置
useEffect(() => {
const loadConfig = async () => {
@@ -248,7 +275,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
const sessions = await listAgentSessions();
const topicList: Topic[] = sessions.map((s: SessionInfo) => ({
id: s.session_id,
title: generateTopicTitle(s),
title: s.title || generateTopicTitle(s),
createdAt: new Date(s.created_at),
messagesCount: s.messages_count,
}));
@@ -258,7 +285,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
}
};
// 根据会话信息生成话题标题
// 根据会话信息生成话题标题(后备方案)
const generateTopicTitle = (session: SessionInfo): string => {
if (session.messages_count === 0) {
return "新话题";
@@ -268,6 +295,42 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
return `话题 ${date.toLocaleDateString("zh-CN")} ${date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
};
// 生成智能标题
const generateSmartTitle = async (targetSessionId: string) => {
// 检查是否已手动编辑过
if (isTitleManuallyEdited(targetSessionId)) {
console.log("[useAgentChat] 标题已手动编辑,跳过自动生成");
return;
}
try {
const title = await generateAgentTitle(targetSessionId);
if (title && title !== "新话题") {
await renameAgentSession(targetSessionId, title);
// 刷新话题列表
await loadTopics();
console.log("[useAgentChat] 智能标题已生成:", title);
}
} catch (error) {
console.warn("[useAgentChat] 生成智能标题失败:", error);
// 静默失败,不影响用户体验
}
};
// 重命名话题
const renameTopic = async (targetSessionId: string, newTitle: string) => {
try {
await renameAgentSession(targetSessionId, newTitle);
// 标记为已手动编辑
setTitleManuallyEdited(targetSessionId, true);
// 刷新话题列表
await loadTopics();
} catch (error) {
console.error("[useAgentChat] 重命名话题失败:", error);
toast.error("重命名失败");
}
};
// Initial Load
useEffect(() => {
getAgentProcessStatus().then(setProcessStatus).catch(console.error);
@@ -409,6 +472,9 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// 保存当前消息 ID 到 ref,用于停止时更新状态
currentAssistantMsgIdRef.current = assistantMsgId;
// 初始化 Artifact 解析器,开始新的解析会话
startArtifactParsing();
// 用于累积流式内容
let accumulatedContent = "";
let unlisten: UnlistenFn | null = null;
@@ -500,6 +566,9 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// 播放打字机音效
playTypewriterSound();
// 流式解析 Artifact
appendArtifactChunk(data.text);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
@@ -561,6 +630,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
case "final_done":
// 整个对话完成(包括所有工具调用)
console.log("[AgentChat] 收到 final_done 事件,对话完成");
// 完成 Artifact 解析
finalizeArtifactParsing();
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
@@ -580,6 +653,25 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
unlisten();
unlisten = null;
}
// 触发智能标题生成(在第 2 轮对话完成后)
// 使用闭包中的 activeSessionId 确保获取正确的会话 ID
setTimeout(() => {
// 从 messages 推断这是第几轮对话
setMessages((currentMessages) => {
const userMsgCount = currentMessages.filter(
(m) => m.role === "user",
).length;
if (userMsgCount === 2 && activeSessionId) {
console.log(
"[useAgentChat] 第 2 轮对话完成,触发智能标题生成",
);
// 异步生成标题,不阻塞消息更新
generateSmartTitle(activeSessionId).catch(console.error);
}
return currentMessages;
});
}, 100);
break;
case "error":
@@ -963,6 +1055,378 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
toast.info("已停止生成");
};
// 触发 AI 引导(不显示用户消息,直接让 AI 开始引导)
const triggerAIGuide = async () => {
// 只创建 assistant 消息占位符,不创建用户消息
const assistantMsgId = crypto.randomUUID();
const assistantMsg: Message = {
id: assistantMsgId,
role: "assistant",
content: "",
timestamp: new Date(),
isThinking: true,
thinkingContent: "正在准备创作引导...",
contentParts: [],
};
setMessages((prev) => [...prev, assistantMsg]);
setIsSending(true);
// 保存当前消息 ID 到 ref,用于停止时更新状态
currentAssistantMsgIdRef.current = assistantMsgId;
// 用于累积流式内容
let accumulatedContent = "";
let unlisten: UnlistenFn | null = null;
// 辅助函数(与 sendMessage 相同)
const appendTextToParts = (
parts: ContentPart[],
text: string,
): ContentPart[] => {
const newParts = [...parts];
const lastPart = newParts[newParts.length - 1];
if (lastPart && lastPart.type === "text") {
newParts[newParts.length - 1] = {
type: "text",
text: lastPart.text + text,
};
} else {
newParts.push({ type: "text", text });
}
return newParts;
};
const appendThinkingToParts = (
parts: ContentPart[],
text: string,
): ContentPart[] => {
const newParts = [...parts];
const lastPart = newParts[newParts.length - 1];
if (lastPart && lastPart.type === "thinking") {
newParts[newParts.length - 1] = {
type: "thinking",
text: lastPart.text + text,
};
} else {
newParts.push({ type: "thinking", text });
}
return newParts;
};
const addActionRequiredToParts = (
parts: ContentPart[],
actionRequired: ActionRequired,
): ContentPart[] => {
const newParts = [...parts];
newParts.push({ type: "action_required", actionRequired });
return newParts;
};
try {
// 确保有一个活跃的 session
const activeSessionId = await _ensureSession();
if (!activeSessionId) {
throw new Error("无法创建或获取会话");
}
// 创建唯一事件名称
const eventName = `agent_stream_${assistantMsgId}`;
// 设置事件监听器(流式接收)
console.log(
`[AgentChat] triggerAIGuide 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`,
);
unlisten = await safeListen<StreamEvent>(eventName, (event) => {
console.log(
"[AgentChat] triggerAIGuide 收到事件:",
eventName,
event.payload,
);
const data = parseStreamEvent(event.payload);
if (!data) {
console.warn(
"[AgentChat] triggerAIGuide 解析事件失败:",
event.payload,
);
return;
}
switch (data.type) {
case "text_delta":
accumulatedContent += data.text;
playTypewriterSound();
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
content: accumulatedContent,
thinkingContent: undefined,
contentParts: appendTextToParts(
msg.contentParts || [],
data.text,
),
}
: msg,
),
);
break;
case "thinking_delta":
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
thinkingContent: (msg.thinkingContent || "") + data.text,
isThinking: true,
contentParts: appendThinkingToParts(
msg.contentParts || [],
data.text,
),
}
: msg,
),
);
break;
case "done":
console.log("[AgentChat] triggerAIGuide 收到 done 事件");
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
content: accumulatedContent || msg.content,
}
: msg,
),
);
break;
case "final_done":
console.log("[AgentChat] triggerAIGuide 收到 final_done 事件");
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
content: accumulatedContent || "(No response)",
}
: msg,
),
);
setIsSending(false);
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
if (unlisten) {
unlisten();
unlisten = null;
}
break;
case "error":
console.error(
"[AgentChat] triggerAIGuide Stream error:",
data.message,
);
toast.error(`响应错误: ${data.message}`, {
id: `stream-error-${Date.now()}`,
duration: 8000,
});
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
content: accumulatedContent || `错误: ${data.message}`,
}
: msg,
),
);
setIsSending(false);
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
if (unlisten) {
unlisten();
unlisten = null;
}
break;
case "tool_start": {
console.log(`[Tool Start] ${data.tool_name} (${data.tool_id})`);
playToolcallSound();
const newToolCall = {
id: data.tool_id,
name: data.tool_name,
arguments: data.arguments,
status: "running" as const,
startTime: new Date(),
};
const toolName = data.tool_name.toLowerCase();
if (toolName.includes("write") || toolName.includes("create")) {
try {
const args = JSON.parse(data.arguments || "{}");
const filePath = args.path || args.file_path || args.filePath;
const content = args.content || args.text || "";
if (filePath && content && onWriteFile) {
onWriteFile(content, filePath);
}
} catch (e) {
console.warn("[Tool Start] 解析工具参数失败:", e);
}
}
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== assistantMsgId) return msg;
const existingToolCall = msg.toolCalls?.find(
(tc) => tc.id === data.tool_id,
);
if (existingToolCall) return msg;
return {
...msg,
toolCalls: [...(msg.toolCalls || []), newToolCall],
contentParts: [
...(msg.contentParts || []),
{ type: "tool_use" as const, toolCall: newToolCall },
],
};
}),
);
break;
}
case "action_required": {
console.log(
`[Action Required] ${data.action_type} (${data.request_id})`,
);
const actionRequired: ActionRequired = {
requestId: data.request_id,
actionType: data.action_type as
| "tool_confirmation"
| "ask_user"
| "elicitation",
toolName: data.tool_name,
arguments: data.arguments,
prompt: data.prompt,
questions: data.questions,
requestedSchema: data.requested_schema,
};
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== assistantMsgId) return msg;
const existingRequest = msg.actionRequests?.find(
(ar) => ar.requestId === data.request_id,
);
if (existingRequest) return msg;
return {
...msg,
actionRequests: [
...(msg.actionRequests || []),
actionRequired,
],
contentParts: addActionRequiredToParts(
msg.contentParts || [],
actionRequired,
),
};
}),
);
break;
}
case "tool_end": {
console.log(`[Tool End] ${data.tool_id}`);
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== assistantMsgId) return msg;
const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
tc.id === data.tool_id
? {
...tc,
status: data.result.success
? ("completed" as const)
: ("failed" as const),
result: data.result,
endTime: new Date(),
}
: tc,
);
const updatedContentParts = (msg.contentParts || []).map(
(part) => {
if (
part.type === "tool_use" &&
part.toolCall.id === data.tool_id
) {
return {
...part,
toolCall: {
...part.toolCall,
status: data.result.success
? ("completed" as const)
: ("failed" as const),
result: data.result,
endTime: new Date(),
},
};
}
return part;
},
);
return {
...msg,
toolCalls: updatedToolCalls,
contentParts: updatedContentParts,
};
}),
);
break;
}
}
});
// 保存 unlisten 到 ref
unlistenRef.current = unlisten;
// 发送空消息,让 AI 根据系统提示词开始引导
console.log("[AgentChat] triggerAIGuide 发送空消息触发引导");
await sendAgentMessageStream(
"", // 空消息,让 AI 根据系统提示词开始引导
eventName,
activeSessionId,
model || undefined,
undefined,
providerType,
);
} catch (error) {
console.error("[AgentChat] triggerAIGuide failed:", error);
toast.error(`启动引导失败: ${error}`, {
id: `guide-error-${Date.now()}`,
duration: 8000,
});
setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId));
setIsSending(false);
if (unlisten) {
unlisten();
}
}
};
// 处理权限确认响应
const handlePermissionResponse = async (response: ConfirmResponse) => {
try {
@@ -1017,6 +1481,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
deleteMessage,
editMessage,
handlePermissionResponse, // 权限确认响应处理
triggerAIGuide, // 触发 AI 引导
// 话题管理
topics,
@@ -1024,5 +1489,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
switchTopic,
deleteTopic,
loadTopics,
renameTopic, // 重命名话题
generateSmartTitle, // 智能标题生成
};
}
+268 -41
View File
@@ -31,26 +31,29 @@ import {
type CanvasState as GeneralCanvasState,
DEFAULT_CANVAS_STATE,
} from "@/components/general-chat/types";
import { artifactsAtom, selectedArtifactAtom } from "@/lib/artifact/store";
import { ArtifactRenderer, ArtifactToolbar } from "@/components/artifact";
import { useAtomValue } from "jotai";
import { createInitialMusicState } from "@/components/content-creator/canvas/music/types";
import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser";
import {
generateContentCreationPrompt,
isContentCreationTheme,
} from "@/components/content-creator/utils/systemPrompt";
import { generateProjectMemoryPrompt } from "@/components/content-creator/utils/projectPrompt";
import {
getProject,
getContent,
updateContent,
type Project,
type ProjectType,
} from "@/lib/api/project";
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
import type { MessageImage } from "./types";
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
// 文件名到步骤索引的映射(静态常量,移到组件外部避免重复创建)
const FILE_TO_STEP_MAP: Record<string, number> = {
"brief.md": 0, // 明确需求
"specification.md": 1, // 调研收集
"research.md": 1, // 调研收集(备选文件名)
"outline.md": 2, // 生成大纲
"draft.md": 3, // 撰写内容
"article.md": 4, // 润色优化
};
import { getFileToStepMap } from "./utils/workflowMapping";
const PageContainer = styled.div`
display: flex;
@@ -86,22 +89,69 @@ const ChatContent = styled.div`
height: 100%;
`;
// 主题到 ThemeType 的映射
const THEME_MAP: Record<string, ThemeType> = {
general: "general",
knowledge: "knowledge",
planning: "planning",
social: "social-media",
image: "poster",
office: "document",
video: "video",
music: "music",
};
/**
* 将 ProjectType 转换为 ThemeType
* 由于类型已统一,大部分情况下直接返回即可
*/
function projectTypeToTheme(projectType: ProjectType): ThemeType {
// ProjectType 和 ThemeType 现在是统一的
// 系统类型 persistent/temporary 映射到 general
if (projectType === "persistent" || projectType === "temporary") {
return "general";
}
return projectType as ThemeType;
}
/**
* 判断画布状态是否为空
* 用于决定是否自动触发 AI 引导
*/
function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean {
if (!state) return true;
switch (state.type) {
case "document":
// 文档画布:检查 content 是否为空
return !state.content || state.content.trim() === "";
case "novel":
// 小说画布:检查第一章内容是否为空
return (
state.chapters.length === 0 ||
!state.chapters[0].content ||
state.chapters[0].content.trim() === ""
);
case "script":
// 剧本画布:检查场景是否有实际内容
return (
state.scenes.length === 0 ||
(state.scenes.length === 1 &&
state.scenes[0].dialogues.length === 0 &&
!state.scenes[0].description)
);
case "music":
// 音乐画布:检查 sections 是否为空
return !state.sections || state.sections.length === 0;
case "poster":
// 海报画布:检查页面中是否有图层
return (
state.pages.length === 0 ||
(state.pages.length === 1 && state.pages[0].layers.length === 0)
);
default:
return true;
}
}
export function AgentChatPage({
onNavigate: _onNavigate,
projectId,
contentId,
onRecommendationClick: _onRecommendationClick,
}: {
onNavigate?: (page: string) => void;
projectId?: string;
contentId?: string;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
}) {
const [showSidebar, setShowSidebar] = useState(false);
const [showSettings, setShowSettings] = useState(false);
@@ -124,6 +174,12 @@ export function AgentChatPage({
const [taskFilesExpanded, setTaskFilesExpanded] = useState(false);
const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
// 项目上下文状态
const [project, setProject] = useState<Project | null>(null);
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(
null,
);
// 用于追踪已处理的消息 ID,避免重复处理
const processedMessageIds = useRef<Set<string>>(new Set());
@@ -132,7 +188,7 @@ export function AgentChatPage({
useRef<(content: string, fileName: string) => void>();
// 工作流状态(仅在内容创作模式下使用)
const mappedTheme = THEME_MAP[activeTheme] || "general";
const mappedTheme = activeTheme as ThemeType;
const { steps, currentStepIndex, goToStep, completeStep } = useWorkflow(
mappedTheme,
creationMode,
@@ -141,13 +197,75 @@ export function AgentChatPage({
// 判断是否为内容创作模式
const isContentCreationMode = isContentCreationTheme(activeTheme);
// 生成系统提示词(仅在内容创作模式下,根据创作模式生成不同提示词)
// Artifact 状态 - 用于在画布中显示
const artifacts = useAtomValue(artifactsAtom);
const selectedArtifact = useAtomValue(selectedArtifactAtom);
// 当有新的 artifact 时,自动打开画布
useEffect(() => {
if (activeTheme !== "general") return;
if (artifacts.length === 0) return;
// 自动打开画布显示 artifact
setLayoutMode("chat-canvas");
}, [artifacts.length, activeTheme]);
// 加载项目、Memory 和内容
useEffect(() => {
const loadData = async () => {
if (!projectId) {
setProject(null);
setProjectMemory(null);
return;
}
// 1. 加载项目
const p = await getProject(projectId);
if (!p) return;
setProject(p);
// 直接使用 projectType 作为 theme(类型已统一)
const theme = projectTypeToTheme(p.workspaceType);
setActiveTheme(theme);
// 2. 加载 Memory
const memory = await getProjectMemory(projectId);
setProjectMemory(memory);
// 3. 如果有 contentId,加载内容并打开画布
if (contentId) {
const content = await getContent(contentId);
if (content) {
const initialState =
createInitialCanvasState(theme, content.body || "") ||
createInitialDocumentState(content.body || "");
setCanvasState(initialState);
setLayoutMode("chat-canvas");
}
}
};
loadData();
}, [projectId, contentId]);
// 生成系统提示词(包含项目 Memory)
const systemPrompt = useMemo(() => {
let prompt = "";
if (isContentCreationMode) {
return generateContentCreationPrompt(mappedTheme, creationMode);
prompt = generateContentCreationPrompt(mappedTheme, creationMode);
}
return undefined;
}, [isContentCreationMode, mappedTheme, creationMode]);
// 注入项目 Memory
if (projectMemory) {
const memoryPrompt = generateProjectMemoryPrompt(projectMemory);
if (memoryPrompt) {
prompt = prompt ? `${prompt}\n\n${memoryPrompt}` : memoryPrompt;
}
}
return prompt || undefined;
}, [isContentCreationMode, mappedTheme, creationMode, projectMemory]);
// 使用 Agent Chat Hook(传递系统提示词)
const {
@@ -164,10 +282,12 @@ export function AgentChatPage({
deleteMessage,
editMessage,
handlePermissionResponse,
triggerAIGuide,
topics,
sessionId,
switchTopic: originalSwitchTopic,
deleteTopic,
renameTopic,
} = useAgentChat({
systemPrompt,
onWriteFile: (content, fileName) => {
@@ -211,15 +331,10 @@ export function AgentChatPage({
console.log("[AgentChatPage] 恢复会话元数据:", sessionId, sessionMeta);
// 从会话元数据恢复主题
// 从会话元数据恢复主题(类型已统一,直接使用)
if (sessionMeta.theme) {
const themeEntry = Object.entries(THEME_MAP).find(
([_, v]) => v === sessionMeta.theme,
);
if (themeEntry) {
console.log("[AgentChatPage] 恢复主题:", themeEntry[0]);
setActiveTheme(themeEntry[0]);
}
console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme);
setActiveTheme(sessionMeta.theme);
}
// 从会话元数据恢复创建模式
@@ -575,8 +690,18 @@ export function AgentChatPage({
console.error("[AgentChatPage] 持久化文件失败:", err);
});
// 根据文件名推进工作流步骤
const stepIndex = FILE_TO_STEP_MAP[fileName];
// 同步内容到项目(如果有 contentId)
if (contentId) {
updateContent(contentId, {
body: content,
}).catch((err) => {
console.error("[AgentChatPage] 同步内容到项目失败:", err);
});
}
// 根据文件名推进工作流步骤(使用动态映射)
const fileToStepMap = getFileToStepMap(mappedTheme);
const stepIndex = fileToStepMap[fileName];
if (
stepIndex !== undefined &&
stepIndex === currentStepIndex &&
@@ -687,6 +812,7 @@ export function AgentChatPage({
},
[
activeTheme, // 添加 activeTheme 依赖
contentId,
currentStepIndex,
isContentCreationMode,
completeStep,
@@ -800,8 +926,29 @@ export function AgentChatPage({
[activeTheme, mappedTheme],
);
// 在画布中打开代码块(General 主题专用)- 通过 onFileClick 触发
// 此函数保留以备将来扩展使用
// 处理代码块点击 - 在画布中显示代码(General 主题专用)
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 shouldCollapseCodeBlocks = useMemo(() => {
if (activeTheme !== "general") return false;
if (layoutMode !== "chat-canvas") return false;
// 当画布打开时折叠代码块
return artifacts.length > 0 || generalCanvasState.isOpen;
}, [activeTheme, layoutMode, artifacts.length, generalCanvasState.isOpen]);
// 处理任务文件点击 - 在画布中显示文件内容
const handleTaskFileClick = useCallback(
@@ -863,6 +1010,54 @@ export function AgentChatPage({
[sendMessage],
);
// 用于追踪是否已触发过 AI 引导
const hasTriggeredGuide = useRef(false);
// 存储 triggerAIGuide 函数引用,避免在 useEffect 依赖中包含函数
const triggerAIGuideRef = useRef(triggerAIGuide);
triggerAIGuideRef.current = triggerAIGuide;
// 当从项目进入且有 contentId 时,自动启动创作引导
useEffect(() => {
// 条件:
// - 有 contentId(从项目创建内容进入)
// - 没有消息(messages.length === 0)
// - 项目已加载
// - 系统提示词已准备好
// - 不在发送中
// - 画布内容为空(canvasState 没有实际内容)
// - 尚未触发过引导
const canvasEmpty = isCanvasStateEmpty(canvasState);
if (
contentId &&
messages.length === 0 &&
project &&
systemPrompt &&
!isSending &&
canvasEmpty &&
!hasTriggeredGuide.current
) {
console.log("[AgentChatPage] 自动触发 AI 创作引导");
hasTriggeredGuide.current = true;
triggerAIGuideRef.current();
}
}, [
contentId,
messages.length,
project,
systemPrompt,
isSending,
canvasState,
]);
// 当 contentId 变化时重置引导状态
useEffect(() => {
hasTriggeredGuide.current = false;
}, [contentId]);
// 判断是否应该显示聊天布局(有消息)
const showChatLayout = hasMessages;
// 聊天区域内容
const chatContent = (
<ChatContainer>
@@ -875,7 +1070,7 @@ export function AgentChatPage({
/>
)}
{hasMessages ? (
{showChatLayout ? (
<ChatContent>
<MessageList
messages={messages}
@@ -885,6 +1080,8 @@ export function AgentChatPage({
onWriteFile={handleWriteFile}
onFileClick={handleFileClick}
onPermissionResponse={handlePermissionResponse}
collapseCodeBlocks={shouldCollapseCodeBlocks}
onCodeBlockClick={handleCodeBlockClick}
/>
</ChatContent>
) : (
@@ -899,10 +1096,14 @@ export function AgentChatPage({
onCreationModeChange={setCreationMode}
activeTheme={activeTheme}
onThemeChange={setActiveTheme}
onRecommendationClick={(shortLabel, fullPrompt) => {
// 直接将推荐提示词放入输入框,不创建项目
setInput(fullPrompt);
}}
/>
)}
{hasMessages && (
{showChatLayout && (
<>
<Inputbar
input={input}
@@ -927,7 +1128,30 @@ export function AgentChatPage({
// 画布区域内容
const canvasContent = useMemo(() => {
// General 主题使用专门的预览画布
// 如果有 artifact,优先使用 ArtifactRenderer 渲染
const currentArtifact =
selectedArtifact ||
(artifacts.length > 0 ? artifacts[artifacts.length - 1] : null);
if (activeTheme === "general" && currentArtifact) {
return (
<div className="flex flex-col h-full bg-[#1e2227]">
{/* 使用 ArtifactToolbar 组件 */}
<ArtifactToolbar
artifact={currentArtifact}
onClose={handleCloseCanvas}
/>
{/* 渲染区域 */}
<div className="flex-1 overflow-auto">
<ArtifactRenderer
artifact={currentArtifact}
isStreaming={currentArtifact.status === "streaming"}
/>
</div>
</div>
);
}
// General 主题使用专门的预览画布(无 artifact 时)
if (activeTheme === "general") {
if (generalCanvasState.isOpen) {
return (
@@ -958,6 +1182,8 @@ export function AgentChatPage({
return null;
}, [
activeTheme,
artifacts,
selectedArtifact,
generalCanvasState,
canvasState,
mappedTheme,
@@ -978,6 +1204,7 @@ export function AgentChatPage({
currentTopicId={sessionId}
onSwitchTopic={switchTopic}
onDeleteTopic={deleteTopic}
onRenameTopic={renameTopic}
/>
)}
@@ -0,0 +1,184 @@
/**
* @file Artifact 占位符工具
* @description 解析消息中的 artifact fence,替换为占位符
* @module components/agent/chat/utils/artifactPlaceholder
*/
/**
* 解析结果
*/
export interface ArtifactPlaceholderResult {
/** 处理后的文本(artifact 被替换为占位符标记) */
processedText: string;
/** 检测到的 artifact 信息 */
artifacts: ArtifactInfo[];
/** 是否有未闭合的 artifact */
hasPending: boolean;
}
/**
* Artifact 信息
*/
export interface ArtifactInfo {
id: string;
type: string;
title: string;
language?: string;
isComplete: boolean;
}
/**
* 占位符标记格式
*/
export const ARTIFACT_PLACEHOLDER_PREFIX = "[[ARTIFACT:";
export const ARTIFACT_PLACEHOLDER_SUFFIX = "]]";
/**
* 解析文本中的 artifact fence,返回处理后的文本和 artifact 信息
*
* @param text - 原始文本
* @param isStreaming - 是否正在流式输出
* @returns 解析结果
*/
export function parseArtifactPlaceholders(
text: string,
isStreaming: boolean = false,
): ArtifactPlaceholderResult {
const artifacts: ArtifactInfo[] = [];
let processedText = text;
let hasPending = false;
// 匹配完整的 artifact fence: ```artifact ... ``` ... ```
const completeArtifactRegex = /```artifact\s+([^`]*?)```([\s\S]*?)```/g;
// 匹配未闭合的 artifact fence(流式输出时)
const pendingArtifactRegex = /```artifact\s+([^`]*?)```([\s\S]*)$/;
// 先处理完整的 artifact
let match: RegExpExecArray | null;
const replacements: Array<{
start: number;
end: number;
placeholder: string;
info: ArtifactInfo;
}> = [];
while ((match = completeArtifactRegex.exec(text)) !== null) {
const attrString = match[1];
const info = parseArtifactAttributes(attrString);
const artifactInfo: ArtifactInfo = {
id: info.id || crypto.randomUUID(),
type: info.type || "code",
title: info.title || "未命名",
language: info.language,
isComplete: true,
};
artifacts.push(artifactInfo);
const placeholder = `${ARTIFACT_PLACEHOLDER_PREFIX}${artifactInfo.id}:${artifactInfo.title}${ARTIFACT_PLACEHOLDER_SUFFIX}`;
replacements.push({
start: match.index,
end: match.index + match[0].length,
placeholder,
info: artifactInfo,
});
}
// 从后往前替换,避免索引偏移
for (let i = replacements.length - 1; i >= 0; i--) {
const { start, end, placeholder } = replacements[i];
processedText =
processedText.slice(0, start) + placeholder + processedText.slice(end);
}
// 检查是否有未闭合的 artifact(流式输出时)
if (isStreaming) {
const pendingMatch = pendingArtifactRegex.exec(processedText);
if (pendingMatch && !processedText.includes(ARTIFACT_PLACEHOLDER_PREFIX)) {
// 有未闭合的 artifact
hasPending = true;
const attrString = pendingMatch[1];
const info = parseArtifactAttributes(attrString);
const artifactInfo: ArtifactInfo = {
id: info.id || crypto.randomUUID(),
type: info.type || "code",
title: info.title || "生成中...",
language: info.language,
isComplete: false,
};
artifacts.push(artifactInfo);
// 替换未闭合的 artifact 为占位符
const placeholder = `${ARTIFACT_PLACEHOLDER_PREFIX}${artifactInfo.id}:${artifactInfo.title}:pending${ARTIFACT_PLACEHOLDER_SUFFIX}`;
processedText = processedText.slice(0, pendingMatch.index) + placeholder;
}
}
return {
processedText,
artifacts,
hasPending,
};
}
/**
* 解析 artifact 属性字符串
*/
function parseArtifactAttributes(attrString: string): {
id?: string;
type?: string;
title?: string;
language?: string;
} {
const result: {
id?: string;
type?: string;
title?: string;
language?: string;
} = {};
// 解析 key="value" 格式
const attrRegex = /(\w+)\s*=\s*["']([^"']*)["']/g;
let match: RegExpExecArray | null;
while ((match = attrRegex.exec(attrString)) !== null) {
const [, key, value] = match;
const normalizedKey = key.toLowerCase();
switch (normalizedKey) {
case "id":
result.id = value;
break;
case "type":
result.type = value;
break;
case "title":
result.title = value;
break;
case "language":
result.language = value;
break;
}
}
return result;
}
/**
* 检查文本是否包含 artifact 占位符
*/
export function hasArtifactPlaceholder(text: string): boolean {
return text.includes(ARTIFACT_PLACEHOLDER_PREFIX);
}
/**
* 从占位符中提取 artifact ID
*/
export function extractArtifactId(placeholder: string): string | null {
const match = placeholder.match(/\[\[ARTIFACT:([^:]+):/);
return match ? match[1] : null;
}
@@ -0,0 +1,327 @@
/**
* @file 工作流文件映射测试
* @description 测试 getFileToStepMap 及相关工具函数
* @module components/agent/chat/utils/workflowMapping.test
*/
import { describe, it, expect } from "vitest";
import {
getFileToStepMap,
getSupportedFilenames,
isWorkflowFile,
getStepIndexForFile,
} from "./workflowMapping";
import type { ThemeType } from "@/components/content-creator/types";
// ============================================================================
// getFileToStepMap 测试
// ============================================================================
describe("getFileToStepMap", () => {
describe("social-media 主题", () => {
it("应该返回正确的文件映射", () => {
const map = getFileToStepMap("social-media");
expect(map["brief.md"]).toBe(0);
expect(map["draft.md"]).toBe(1);
expect(map["article.md"]).toBe(2);
expect(map["adapted.md"]).toBe(3);
});
it("应该包含 4 个文件映射", () => {
const map = getFileToStepMap("social-media");
expect(Object.keys(map).length).toBe(4);
});
});
describe("video 主题", () => {
it("应该返回正确的文件映射", () => {
const map = getFileToStepMap("video");
expect(map["brief.md"]).toBe(0);
expect(map["outline.md"]).toBe(1);
expect(map["storyboard.md"]).toBe(2);
expect(map["script.md"]).toBe(3);
expect(map["script-final.md"]).toBe(4);
});
it("应该包含 5 个文件映射", () => {
const map = getFileToStepMap("video");
expect(Object.keys(map).length).toBe(5);
});
});
describe("novel 主题", () => {
it("应该返回正确的文件映射", () => {
const map = getFileToStepMap("novel");
expect(map["brief.md"]).toBe(0);
expect(map["outline.md"]).toBe(1);
expect(map["characters.md"]).toBe(2);
expect(map["chapter.md"]).toBe(3);
expect(map["chapter-final.md"]).toBe(4);
});
it("应该包含 5 个文件映射", () => {
const map = getFileToStepMap("novel");
expect(Object.keys(map).length).toBe(5);
});
});
describe("document 主题", () => {
it("应该返回正确的文件映射", () => {
const map = getFileToStepMap("document");
expect(map["brief.md"]).toBe(0);
expect(map["outline.md"]).toBe(1);
expect(map["draft.md"]).toBe(2);
expect(map["article.md"]).toBe(3);
});
it("应该包含 4 个文件映射", () => {
const map = getFileToStepMap("document");
expect(Object.keys(map).length).toBe(4);
});
});
describe("music 主题", () => {
it("应该返回正确的文件映射", () => {
const map = getFileToStepMap("music");
expect(map["song-spec.md"]).toBe(0);
expect(map["lyrics-draft.md"]).toBe(1);
expect(map["lyrics-final.txt"]).toBe(2);
});
it("应该包含 3 个文件映射", () => {
const map = getFileToStepMap("music");
expect(Object.keys(map).length).toBe(3);
});
});
describe("poster 主题", () => {
it("应该返回正确的文件映射", () => {
const map = getFileToStepMap("poster");
expect(map["brief.md"]).toBe(0);
expect(map["copywriting.md"]).toBe(1);
expect(map["layout.md"]).toBe(2);
expect(map["design.md"]).toBe(3);
});
it("应该包含 4 个文件映射", () => {
const map = getFileToStepMap("poster");
expect(Object.keys(map).length).toBe(4);
});
});
describe("无工作流的主题", () => {
it("general 应该返回空映射", () => {
const map = getFileToStepMap("general");
expect(Object.keys(map).length).toBe(0);
});
it("knowledge 应该返回空映射", () => {
const map = getFileToStepMap("knowledge");
expect(Object.keys(map).length).toBe(0);
});
it("planning 应该返回空映射", () => {
const map = getFileToStepMap("planning");
expect(Object.keys(map).length).toBe(0);
});
});
describe("覆盖所有主题类型", () => {
it("应该覆盖所有 ThemeType", () => {
const allThemes: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
allThemes.forEach((theme) => {
// 不应该抛出错误
expect(() => getFileToStepMap(theme)).not.toThrow();
});
});
it("有工作流的主题数量应该是 6 种", () => {
const allThemes: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
const themesWithWorkflow = allThemes.filter(
(theme) => Object.keys(getFileToStepMap(theme)).length > 0,
);
expect(themesWithWorkflow.length).toBe(6);
});
});
describe("步骤索引连续性", () => {
it("social-media 步骤索引应该从 0 开始连续", () => {
const map = getFileToStepMap("social-media");
const indices = Object.values(map).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2, 3]);
});
it("video 步骤索引应该从 0 开始连续", () => {
const map = getFileToStepMap("video");
const indices = Object.values(map).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2, 3, 4]);
});
it("novel 步骤索引应该从 0 开始连续", () => {
const map = getFileToStepMap("novel");
const indices = Object.values(map).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2, 3, 4]);
});
it("document 步骤索引应该从 0 开始连续", () => {
const map = getFileToStepMap("document");
const indices = Object.values(map).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2, 3]);
});
it("music 步骤索引应该从 0 开始连续", () => {
const map = getFileToStepMap("music");
const indices = Object.values(map).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2]);
});
it("poster 步骤索引应该从 0 开始连续", () => {
const map = getFileToStepMap("poster");
const indices = Object.values(map).sort((a, b) => a - b);
expect(indices).toEqual([0, 1, 2, 3]);
});
});
});
// ============================================================================
// getSupportedFilenames 测试
// ============================================================================
describe("getSupportedFilenames", () => {
it("应该返回 social-media 支持的所有文件名", () => {
const filenames = getSupportedFilenames("social-media");
expect(filenames).toContain("brief.md");
expect(filenames).toContain("draft.md");
expect(filenames).toContain("article.md");
expect(filenames).toContain("adapted.md");
expect(filenames.length).toBe(4);
});
it("应该返回 video 支持的所有文件名", () => {
const filenames = getSupportedFilenames("video");
expect(filenames).toContain("brief.md");
expect(filenames).toContain("outline.md");
expect(filenames).toContain("storyboard.md");
expect(filenames).toContain("script.md");
expect(filenames).toContain("script-final.md");
expect(filenames.length).toBe(5);
});
it("无工作流的主题应该返回空数组", () => {
expect(getSupportedFilenames("general")).toEqual([]);
expect(getSupportedFilenames("knowledge")).toEqual([]);
expect(getSupportedFilenames("planning")).toEqual([]);
});
});
// ============================================================================
// isWorkflowFile 测试
// ============================================================================
describe("isWorkflowFile", () => {
it("应该正确识别 social-media 的工作流文件", () => {
expect(isWorkflowFile("social-media", "brief.md")).toBe(true);
expect(isWorkflowFile("social-media", "draft.md")).toBe(true);
expect(isWorkflowFile("social-media", "article.md")).toBe(true);
expect(isWorkflowFile("social-media", "adapted.md")).toBe(true);
expect(isWorkflowFile("social-media", "unknown.md")).toBe(false);
});
it("应该正确识别 video 的工作流文件", () => {
expect(isWorkflowFile("video", "brief.md")).toBe(true);
expect(isWorkflowFile("video", "script.md")).toBe(true);
expect(isWorkflowFile("video", "lyrics.md")).toBe(false);
});
it("应该正确识别 music 的工作流文件", () => {
expect(isWorkflowFile("music", "song-spec.md")).toBe(true);
expect(isWorkflowFile("music", "lyrics-draft.md")).toBe(true);
expect(isWorkflowFile("music", "lyrics-final.txt")).toBe(true);
expect(isWorkflowFile("music", "brief.md")).toBe(false);
});
it("无工作流的主题应该对所有文件返回 false", () => {
expect(isWorkflowFile("general", "brief.md")).toBe(false);
expect(isWorkflowFile("general", "any-file.md")).toBe(false);
expect(isWorkflowFile("knowledge", "brief.md")).toBe(false);
expect(isWorkflowFile("planning", "brief.md")).toBe(false);
});
});
// ============================================================================
// getStepIndexForFile 测试
// ============================================================================
describe("getStepIndexForFile", () => {
it("应该返回正确的步骤索引", () => {
expect(getStepIndexForFile("social-media", "brief.md")).toBe(0);
expect(getStepIndexForFile("social-media", "draft.md")).toBe(1);
expect(getStepIndexForFile("video", "script.md")).toBe(3);
expect(getStepIndexForFile("music", "lyrics-final.txt")).toBe(2);
});
it("不存在的文件应该返回 undefined", () => {
expect(getStepIndexForFile("social-media", "unknown.md")).toBeUndefined();
expect(getStepIndexForFile("video", "lyrics.md")).toBeUndefined();
});
it("无工作流的主题应该返回 undefined", () => {
expect(getStepIndexForFile("general", "brief.md")).toBeUndefined();
expect(getStepIndexForFile("knowledge", "any.md")).toBeUndefined();
});
});
// ============================================================================
// 跨主题文件名冲突测试
// ============================================================================
describe("跨主题文件名", () => {
it("brief.md 在不同主题中都应该是步骤 0", () => {
// brief.md 是多个主题的第一步
expect(getStepIndexForFile("social-media", "brief.md")).toBe(0);
expect(getStepIndexForFile("video", "brief.md")).toBe(0);
expect(getStepIndexForFile("novel", "brief.md")).toBe(0);
expect(getStepIndexForFile("document", "brief.md")).toBe(0);
expect(getStepIndexForFile("poster", "brief.md")).toBe(0);
// music 使用不同的第一步文件名
expect(getStepIndexForFile("music", "brief.md")).toBeUndefined();
});
it("draft.md 在不同主题中可能有不同的步骤索引", () => {
expect(getStepIndexForFile("social-media", "draft.md")).toBe(1);
expect(getStepIndexForFile("document", "draft.md")).toBe(2);
// video 和 novel 不使用 draft.md
expect(getStepIndexForFile("video", "draft.md")).toBeUndefined();
expect(getStepIndexForFile("novel", "draft.md")).toBeUndefined();
});
it("outline.md 在不同主题中可能有不同的步骤索引", () => {
expect(getStepIndexForFile("video", "outline.md")).toBe(1);
expect(getStepIndexForFile("novel", "outline.md")).toBe(1);
expect(getStepIndexForFile("document", "outline.md")).toBe(1);
// social-media 不使用 outline.md
expect(getStepIndexForFile("social-media", "outline.md")).toBeUndefined();
});
});
@@ -0,0 +1,103 @@
/**
* @file 工作流文件映射工具
* @description 根据主题类型获取文件名到步骤索引的映射
* @module components/agent/chat/utils/workflowMapping
*/
import type { ThemeType } from "@/components/content-creator/types";
/**
* 根据主题类型获取文件名到步骤索引的映射
* 不同类型的工作流使用不同的文件名映射
*
* 映射关系与 useWorkflow.ts 中的步骤定义保持一致:
* - social-media: 3-4 步(brief → draft → article → adapted)
* - video: 3-5 步(brief → outline → storyboard → script → script-final)
* - novel: 3-5 步(brief → outline → characters → chapter → chapter-final)
* - document: 3-4 步(brief → outline → draft → article)
* - music: 3-7 步(song-spec → lyrics-draft → lyrics-final)
* - poster: 3-5 步(brief → copywriting → layout → design)
* - general/knowledge/planning: 无工作流
*/
export function getFileToStepMap(theme: ThemeType): Record<string, number> {
switch (theme) {
case "social-media":
return {
"brief.md": 0, // 明确需求
"draft.md": 1, // 创作内容
"article.md": 2, // 润色优化(引导模式)
"adapted.md": 3, // 平台适配(引导模式)
};
case "video":
return {
"brief.md": 0, // 明确需求
"outline.md": 1, // 剧情大纲
"storyboard.md": 2, // 分镜设计
"script.md": 3, // 撰写剧本
"script-final.md": 4, // 润色优化
};
case "novel":
return {
"brief.md": 0, // 明确需求
"outline.md": 1, // 章节大纲
"characters.md": 2, // 角色设定
"chapter.md": 3, // 撰写内容
"chapter-final.md": 4, // 润色优化
};
case "document":
return {
"brief.md": 0, // 明确需求
"outline.md": 1, // 文档大纲
"draft.md": 2, // 撰写内容
"article.md": 3, // 润色优化
};
case "music":
return {
"song-spec.md": 0, // 歌曲规格
"lyrics-draft.md": 1, // 歌词初稿(快速模式直接到这里)
"lyrics-final.txt": 2, // 歌词终稿
};
case "poster":
return {
"brief.md": 0, // 需求分析
"copywriting.md": 1, // 文案策划
"layout.md": 2, // 布局设计
"design.md": 3, // 视觉设计
};
// general, knowledge, planning 不需要映射(无工作流)
default:
return {};
}
}
/**
* 获取主题支持的所有文件名列表
*/
export function getSupportedFilenames(theme: ThemeType): string[] {
return Object.keys(getFileToStepMap(theme));
}
/**
* 检查文件名是否属于指定主题的工作流
*/
export function isWorkflowFile(theme: ThemeType, filename: string): boolean {
const map = getFileToStepMap(theme);
return filename in map;
}
/**
* 获取文件对应的步骤索引,如果不存在返回 undefined
*/
export function getStepIndexForFile(
theme: ThemeType,
filename: string,
): number | undefined {
const map = getFileToStepMap(theme);
return map[filename];
}
+212
View File
@@ -0,0 +1,212 @@
/**
* @file Artifact 列表组件
* @description 显示当前消息中的所有 artifacts,支持选择交互
* @module components/artifact/ArtifactList
* @requirements 10.2
*/
import React, { memo, useCallback } from "react";
import {
Code,
Globe,
Image,
GitBranch,
Component,
FileText,
Music,
Film,
BookOpen,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { Artifact, ArtifactType } from "@/lib/artifact/types";
/**
* ArtifactList Props
*/
export interface ArtifactListProps {
/** Artifact 列表 */
artifacts: Artifact[];
/** 当前选中的 Artifact ID */
selectedId?: string | null;
/** 选择回调 */
onSelect?: (id: string) => void;
/** 自定义类名 */
className?: string;
}
/**
* 获取 Artifact 类型对应的图标组件
* @param type - Artifact 类型
* @returns 图标组件
*/
function getTypeIcon(type: ArtifactType): React.ReactNode {
const iconClass = "w-4 h-4";
switch (type) {
case "code":
return <Code className={iconClass} />;
case "html":
return <Globe className={iconClass} />;
case "svg":
return <Image className={iconClass} />;
case "mermaid":
return <GitBranch className={iconClass} />;
case "react":
return <Component className={iconClass} />;
case "canvas:document":
return <FileText className={iconClass} />;
case "canvas:poster":
return <Image className={iconClass} />;
case "canvas:music":
return <Music className={iconClass} />;
case "canvas:script":
return <Film className={iconClass} />;
case "canvas:novel":
return <BookOpen className={iconClass} />;
default:
return <FileText className={iconClass} />;
}
}
/**
* 获取 Artifact 类型的显示名称
* @param type - Artifact 类型
* @returns 显示名称
*/
function getTypeDisplayName(type: ArtifactType): string {
const displayNames: Record<ArtifactType, string> = {
code: "代码",
html: "HTML",
svg: "SVG",
mermaid: "图表",
react: "React",
"canvas:document": "文档",
"canvas:poster": "海报",
"canvas:music": "音乐",
"canvas:script": "剧本",
"canvas:novel": "小说",
};
return displayNames[type] || type;
}
/**
* 单个 Artifact 列表项 Props
*/
interface ArtifactListItemProps {
artifact: Artifact;
isSelected: boolean;
onSelect: () => void;
}
/**
* 单个 Artifact 列表项组件
*/
const ArtifactListItem: React.FC<ArtifactListItemProps> = memo(
({ artifact, isSelected, onSelect }) => {
const isStreaming = artifact.status === "streaming";
return (
<button
onClick={onSelect}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-all",
"hover:bg-white/5 focus:outline-none focus:ring-1 focus:ring-white/20",
isSelected
? "bg-white/10 text-white border-l-2 border-blue-500"
: "text-gray-400 border-l-2 border-transparent",
)}
>
{/* 类型图标 */}
<span
className={cn(
"shrink-0",
isSelected ? "text-blue-400" : "text-gray-500",
)}
>
{getTypeIcon(artifact.type)}
</span>
{/* 标题和类型 */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{artifact.title}</div>
<div className="text-xs text-gray-500 truncate">
{getTypeDisplayName(artifact.type)}
{artifact.meta.language && ` · ${artifact.meta.language}`}
</div>
</div>
{/* 流式状态指示器 */}
{isStreaming && (
<Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />
)}
</button>
);
},
);
ArtifactListItem.displayName = "ArtifactListItem";
/**
* Artifact 列表组件
*
* 功能特性:
* - 显示 artifact 列表,包含类型图标和标题 (Requirement 10.2)
* - 支持选择交互,高亮选中项
* - 显示流式状态指示器(streaming 状态时显示加载动画)
* - 紧凑的垂直布局,适合侧边面板
*
* @param artifacts - Artifact 列表
* @param selectedId - 当前选中的 Artifact ID
* @param onSelect - 选择回调
* @param className - 自定义类名
*/
export const ArtifactList: React.FC<ArtifactListProps> = memo(
({ artifacts, selectedId, onSelect, className }) => {
/**
* 处理选择事件
*/
const handleSelect = useCallback(
(id: string) => {
onSelect?.(id);
},
[onSelect],
);
// 如果没有 artifacts,不渲染
if (artifacts.length === 0) {
return null;
}
return (
<div
className={cn(
"flex flex-col border-b border-white/10 bg-[#1e2227]",
className,
)}
>
{/* 列表标题 */}
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
Artifacts ({artifacts.length})
</div>
{/* 列表内容 */}
<div className="max-h-48 overflow-y-auto">
{artifacts.map((artifact) => (
<ArtifactListItem
key={artifact.id}
artifact={artifact}
isSelected={artifact.id === selectedId}
onSelect={() => handleSelect(artifact.id)}
/>
))}
</div>
</div>
);
},
);
ArtifactList.displayName = "ArtifactList";
export default ArtifactList;
@@ -0,0 +1,377 @@
/**
* @file Artifact 面板属性测试
* @description 使用 fast-check 进行属性测试,验证面板宽度持久化的正确性
* @module components/artifact/ArtifactPanel.test
* @requirements 10.6
*/
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import * as fc from "fast-check";
import { createStore } from "jotai";
import type { ArtifactPanelState } from "@/lib/artifact/store";
// ============================================================================
// 常量定义(与 ArtifactPanel.tsx 保持一致)
// ============================================================================
/** 面板最小宽度 */
const MIN_PANEL_WIDTH = 320;
/** 面板最大宽度 */
const MAX_PANEL_WIDTH = 800;
/** 面板默认宽度 */
const DEFAULT_PANEL_WIDTH = 480;
/** sessionStorage 键名 */
const STORAGE_KEY = "artifact-panel";
// ============================================================================
// 辅助函数
// ============================================================================
/**
* 将宽度限制在有效范围内
* @param width - 原始宽度
* @returns 限制后的宽度
*/
function clampWidth(width: number): number {
return Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width));
}
/**
* 模拟设置面板宽度(直接操作 sessionStorage)
* @param width - 要设置的宽度
*/
function setPanelWidth(width: number): void {
const state: ArtifactPanelState = {
isOpen: true,
width: clampWidth(width),
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
/**
* 从 sessionStorage 获取面板宽度
* @returns 面板宽度,如果不存在则返回默认值
*/
function getPanelWidth(): number {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (!stored) return DEFAULT_PANEL_WIDTH;
try {
const state = JSON.parse(stored) as ArtifactPanelState;
return state.width;
} catch {
return DEFAULT_PANEL_WIDTH;
}
}
/**
* 模拟页面重新加载(清除内存状态,保留 sessionStorage)
* 返回新的 Jotai store 实例
*/
function simulateReload(): ReturnType<typeof createStore> {
return createStore();
}
// ============================================================================
// 自定义生成器 (Arbitraries)
// ============================================================================
/** 有效宽度生成器(在 MIN_WIDTH 和 MAX_WIDTH 之间) */
const validWidthArb = fc.integer({
min: MIN_PANEL_WIDTH,
max: MAX_PANEL_WIDTH,
});
/** 任意宽度生成器(可能超出范围) */
const anyWidthArb = fc.integer({ min: 0, max: 2000 });
/** 小于最小宽度的生成器 */
const belowMinWidthArb = fc.integer({ min: 0, max: MIN_PANEL_WIDTH - 1 });
/** 大于最大宽度的生成器 */
const aboveMaxWidthArb = fc.integer({ min: MAX_PANEL_WIDTH + 1, max: 2000 });
/** 宽度调整序列生成器 */
const widthSequenceArb = fc.array(validWidthArb, {
minLength: 1,
maxLength: 10,
});
// ============================================================================
// Property 9: 面板宽度持久化
// **Validates: Requirements 10.6**
// ============================================================================
describe("Property 9: 面板宽度持久化", () => {
// 每个测试前清理 sessionStorage
beforeEach(() => {
sessionStorage.clear();
});
// 每个测试后清理 sessionStorage
afterEach(() => {
sessionStorage.clear();
});
/**
* **Validates: Requirements 10.6**
* 对于任何有效宽度,设置后应能正确读取
*/
test("有效宽度应被正确持久化", () => {
fc.assert(
fc.property(validWidthArb, (width) => {
// 设置宽度
setPanelWidth(width);
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度一致
expect(storedWidth).toBe(width);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 对于任何有效宽度,重新加载后应保持一致
*/
test("有效宽度应在重新加载后保持一致", () => {
fc.assert(
fc.property(validWidthArb, (width) => {
// 设置宽度
setPanelWidth(width);
// 模拟重新加载
simulateReload();
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度一致
expect(storedWidth).toBe(width);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 小于最小宽度的值应被限制到最小宽度
*/
test("小于最小宽度的值应被限制到最小宽度", () => {
fc.assert(
fc.property(belowMinWidthArb, (width) => {
// 设置宽度(会被限制)
setPanelWidth(width);
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度被限制到最小值
expect(storedWidth).toBe(MIN_PANEL_WIDTH);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 大于最大宽度的值应被限制到最大宽度
*/
test("大于最大宽度的值应被限制到最大宽度", () => {
fc.assert(
fc.property(aboveMaxWidthArb, (width) => {
// 设置宽度(会被限制)
setPanelWidth(width);
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度被限制到最大值
expect(storedWidth).toBe(MAX_PANEL_WIDTH);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 任意宽度都应被限制在有效范围内
*/
test("任意宽度都应被限制在有效范围内", () => {
fc.assert(
fc.property(anyWidthArb, (width) => {
// 设置宽度
setPanelWidth(width);
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度在有效范围内
expect(storedWidth).toBeGreaterThanOrEqual(MIN_PANEL_WIDTH);
expect(storedWidth).toBeLessThanOrEqual(MAX_PANEL_WIDTH);
// 验证宽度等于限制后的值
expect(storedWidth).toBe(clampWidth(width));
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 多次宽度调整后,最后一次设置的值应被保留
*/
test("多次宽度调整后应保留最后一次设置的值", () => {
fc.assert(
fc.property(widthSequenceArb, (widths) => {
// 依次设置宽度
for (const width of widths) {
setPanelWidth(width);
}
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度等于最后一次设置的值
const lastWidth = widths[widths.length - 1];
expect(storedWidth).toBe(lastWidth);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 多次宽度调整后,重新加载应保留最后一次设置的值
*/
test("多次宽度调整后重新加载应保留最后一次设置的值", () => {
fc.assert(
fc.property(widthSequenceArb, (widths) => {
// 依次设置宽度
for (const width of widths) {
setPanelWidth(width);
}
// 模拟重新加载
simulateReload();
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度等于最后一次设置的值
const lastWidth = widths[widths.length - 1];
expect(storedWidth).toBe(lastWidth);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* 边界值测试:最小宽度应被正确持久化
*/
test("最小宽度边界值应被正确持久化", () => {
setPanelWidth(MIN_PANEL_WIDTH);
expect(getPanelWidth()).toBe(MIN_PANEL_WIDTH);
// 重新加载后仍然保持
simulateReload();
expect(getPanelWidth()).toBe(MIN_PANEL_WIDTH);
});
/**
* **Validates: Requirements 10.6**
* 边界值测试:最大宽度应被正确持久化
*/
test("最大宽度边界值应被正确持久化", () => {
setPanelWidth(MAX_PANEL_WIDTH);
expect(getPanelWidth()).toBe(MAX_PANEL_WIDTH);
// 重新加载后仍然保持
simulateReload();
expect(getPanelWidth()).toBe(MAX_PANEL_WIDTH);
});
/**
* **Validates: Requirements 10.6**
* 默认值测试:未设置时应返回默认宽度
*/
test("未设置时应返回默认宽度", () => {
// 不设置任何值
const width = getPanelWidth();
expect(width).toBe(DEFAULT_PANEL_WIDTH);
});
/**
* **Validates: Requirements 10.6**
* 无效 JSON 测试:损坏的存储数据应返回默认宽度
*/
test("损坏的存储数据应返回默认宽度", () => {
// 设置无效的 JSON
sessionStorage.setItem(STORAGE_KEY, "invalid json");
const width = getPanelWidth();
expect(width).toBe(DEFAULT_PANEL_WIDTH);
});
/**
* **Validates: Requirements 10.6**
* 幂等性测试:相同宽度多次设置应保持一致
*/
test("相同宽度多次设置应保持一致", () => {
fc.assert(
fc.property(
validWidthArb,
fc.integer({ min: 1, max: 10 }),
(width, repeatCount) => {
// 多次设置相同宽度
for (let i = 0; i < repeatCount; i++) {
setPanelWidth(width);
}
// 读取宽度
const storedWidth = getPanelWidth();
// 验证宽度一致
expect(storedWidth).toBe(width);
},
),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 10.6**
* clampWidth 函数应正确限制宽度
*/
test("clampWidth 函数应正确限制宽度", () => {
fc.assert(
fc.property(anyWidthArb, (width) => {
const clamped = clampWidth(width);
// 结果应在有效范围内
expect(clamped).toBeGreaterThanOrEqual(MIN_PANEL_WIDTH);
expect(clamped).toBeLessThanOrEqual(MAX_PANEL_WIDTH);
// 如果原值在范围内,应保持不变
if (width >= MIN_PANEL_WIDTH && width <= MAX_PANEL_WIDTH) {
expect(clamped).toBe(width);
}
// 如果原值小于最小值,应等于最小值
if (width < MIN_PANEL_WIDTH) {
expect(clamped).toBe(MIN_PANEL_WIDTH);
}
// 如果原值大于最大值,应等于最大值
if (width > MAX_PANEL_WIDTH) {
expect(clamped).toBe(MAX_PANEL_WIDTH);
}
}),
{ numRuns: 100 },
);
});
});
+391
View File
@@ -0,0 +1,391 @@
/**
* @file Artifact 侧边面板组件
* @description 可调整大小的侧边面板,集成 ArtifactList、ArtifactToolbar、ArtifactRenderer
* @module components/artifact/ArtifactPanel
* @requirements 10.1, 10.3, 10.4, 10.5, 10.6
*/
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { X, ChevronLeft, ChevronRight, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
import {
artifactsAtom,
selectedArtifactAtom,
artifactPanelStateAtom,
artifactActionsAtom,
} from "@/lib/artifact/store";
import { ArtifactList } from "./ArtifactList";
import { ArtifactToolbar } from "./ArtifactToolbar";
import { ArtifactRenderer } from "./ArtifactRenderer";
// ============================================================================
// 常量定义
// ============================================================================
/** 面板最小宽度 */
const MIN_PANEL_WIDTH = 320;
/** 面板最大宽度 */
const MAX_PANEL_WIDTH = 800;
// ============================================================================
// 辅助组件
// ============================================================================
/**
* 空状态组件
*/
const EmptyState: React.FC<{ message: string }> = memo(({ message }) => (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-gray-400">
<div className="w-16 h-16 mb-4 rounded-full bg-white/5 flex items-center justify-center">
<span className="text-2xl">📄</span>
</div>
<p className="text-sm">{message}</p>
</div>
));
EmptyState.displayName = "EmptyState";
/**
* 拖拽手柄组件
*/
interface ResizeHandleProps {
onMouseDown: (e: React.MouseEvent) => void;
isResizing: boolean;
}
const ResizeHandle: React.FC<ResizeHandleProps> = memo(
({ onMouseDown, isResizing }) => (
<div
className={cn(
"absolute left-0 top-0 bottom-0 w-1 cursor-col-resize group",
"hover:bg-blue-500/50 transition-colors",
isResizing && "bg-blue-500/50",
)}
onMouseDown={onMouseDown}
>
<div
className={cn(
"absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2",
"w-4 h-8 flex items-center justify-center",
"opacity-0 group-hover:opacity-100 transition-opacity",
isResizing && "opacity-100",
)}
>
<GripVertical className="w-4 h-4 text-gray-400" />
</div>
</div>
),
);
ResizeHandle.displayName = "ResizeHandle";
/**
* 折叠状态面板
*/
interface CollapsedPanelProps {
artifactCount: number;
onExpand: () => void;
}
const CollapsedPanel: React.FC<CollapsedPanelProps> = memo(
({ artifactCount, onExpand }) => (
<div className="h-full w-12 bg-[#1e2227] border-l border-white/10 flex flex-col items-center py-4">
<button
onClick={onExpand}
className="flex flex-col items-center gap-2 p-2 rounded hover:bg-white/10 transition-colors"
title="展开面板"
>
<ChevronLeft className="w-5 h-5 text-gray-400" />
{artifactCount > 0 && (
<span className="text-xs text-gray-400 bg-white/10 px-1.5 py-0.5 rounded">
{artifactCount}
</span>
)}
</button>
</div>
),
);
CollapsedPanel.displayName = "CollapsedPanel";
// ============================================================================
// 主组件
// ============================================================================
/**
* ArtifactPanel Props
*/
export interface ArtifactPanelProps {
/** 关闭面板回调 */
onClose?: () => void;
/** 自定义类名 */
className?: string;
}
/**
* Artifact 侧边面板组件
*
* 功能特性:
* - 可调整大小的侧边面板 (Requirement 10.1)
* - 集成 ArtifactList、ArtifactToolbar、ArtifactRenderer
* - 键盘导航:上下键选择 artifact (Requirement 10.3)
* - 快捷键:Escape 关闭面板 (Requirement 10.4)
* - 支持展开/折叠 (Requirement 10.5)
* - 宽度持久化 (Requirement 10.6)
*
* @param onClose - 关闭面板回调
* @param className - 自定义类名
*/
export const ArtifactPanel: React.FC<ArtifactPanelProps> = memo(
({ onClose, className }) => {
// ============================================================================
// 状态管理
// ============================================================================
// Jotai atoms
const artifacts = useAtomValue(artifactsAtom);
const selectedArtifact = useAtomValue(selectedArtifactAtom);
const [panelState, setPanelState] = useAtom(artifactPanelStateAtom);
const dispatch = useSetAtom(artifactActionsAtom);
// 本地状态
const [isResizing, setIsResizing] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const [showSource, setShowSource] = useState(false);
// Refs
const panelRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
// ============================================================================
// 宽度调整逻辑
// ============================================================================
/**
* 开始拖拽调整宽度
*/
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startXRef.current = e.clientX;
startWidthRef.current = panelState.width;
},
[panelState.width],
);
/**
* 拖拽过程中更新宽度
*/
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
// 向左拖拽增加宽度,向右拖拽减少宽度
const delta = startXRef.current - e.clientX;
const newWidth = Math.min(
MAX_PANEL_WIDTH,
Math.max(MIN_PANEL_WIDTH, startWidthRef.current + delta),
);
setPanelState((prev) => ({ ...prev, width: newWidth }));
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, setPanelState]);
// ============================================================================
// 键盘导航
// ============================================================================
/**
* 处理键盘事件
* - 上下键选择 artifact (Requirement 10.3)
* - Escape 关闭面板 (Requirement 10.4)
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape 关闭面板
if (e.key === "Escape") {
e.preventDefault();
onClose?.();
return;
}
// 上下键导航
if (artifacts.length === 0) return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const currentIndex = selectedArtifact
? artifacts.findIndex((a) => a.id === selectedArtifact.id)
: -1;
let newIndex: number;
if (e.key === "ArrowUp") {
// 向上选择
newIndex =
currentIndex <= 0 ? artifacts.length - 1 : currentIndex - 1;
} else {
// 向下选择
newIndex =
currentIndex >= artifacts.length - 1 ? 0 : currentIndex + 1;
}
dispatch({ type: "select", id: artifacts[newIndex].id });
}
};
// 只在面板获得焦点时监听键盘事件
const panel = panelRef.current;
if (panel) {
panel.addEventListener("keydown", handleKeyDown);
return () => panel.removeEventListener("keydown", handleKeyDown);
}
}, [artifacts, selectedArtifact, dispatch, onClose]);
// ============================================================================
// 事件处理
// ============================================================================
/**
* 选择 artifact
*/
const handleSelectArtifact = useCallback(
(id: string) => {
dispatch({ type: "select", id });
setShowSource(false); // 切换 artifact 时重置源码视图
},
[dispatch],
);
/**
* 切换源码视图
*/
const handleToggleSource = useCallback(() => {
setShowSource((prev) => !prev);
}, []);
/**
* 关闭面板
*/
const handleClose = useCallback(() => {
onClose?.();
}, [onClose]);
/**
* 展开面板
*/
const handleExpand = useCallback(() => {
setIsCollapsed(false);
}, []);
/**
* 折叠面板
*/
const handleCollapse = useCallback(() => {
setIsCollapsed(true);
}, []);
// ============================================================================
// 渲染
// ============================================================================
// 折叠状态
if (isCollapsed) {
return (
<CollapsedPanel
artifactCount={artifacts.length}
onExpand={handleExpand}
/>
);
}
return (
<div
ref={panelRef}
tabIndex={0}
className={cn(
"relative h-full flex flex-col bg-[#1e2227] border-l border-white/10",
"focus:outline-none",
className,
)}
style={{ width: panelState.width }}
>
{/* 拖拽调整宽度手柄 */}
<ResizeHandle onMouseDown={handleResizeStart} isResizing={isResizing} />
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-white/10 bg-[#21252b]">
<h3 className="text-sm font-medium text-white">Artifacts</h3>
<div className="flex items-center gap-1">
{/* 折叠按钮 */}
<button
onClick={handleCollapse}
className="flex items-center justify-center w-7 h-7 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="折叠面板"
>
<ChevronRight className="w-4 h-4" />
</button>
{/* 关闭按钮 */}
<button
onClick={handleClose}
className="flex items-center justify-center w-7 h-7 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="关闭面板"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Artifact 列表(多个 artifact 时显示) */}
{artifacts.length > 1 && (
<ArtifactList
artifacts={artifacts}
selectedId={selectedArtifact?.id}
onSelect={handleSelectArtifact}
/>
)}
{/* 渲染区域 */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedArtifact ? (
<>
{/* 工具栏 */}
<ArtifactToolbar
artifact={selectedArtifact}
showSource={showSource}
onToggleSource={handleToggleSource}
onClose={handleClose}
/>
{/* 渲染器 */}
<div className="flex-1 overflow-auto">
<ArtifactRenderer
artifact={selectedArtifact}
isStreaming={selectedArtifact.status === "streaming"}
/>
</div>
</>
) : artifacts.length > 0 ? (
<EmptyState message="选择一个 Artifact 查看" />
) : (
<EmptyState message="暂无 Artifact" />
)}
</div>
</div>
);
},
);
ArtifactPanel.displayName = "ArtifactPanel";
export default ArtifactPanel;
@@ -0,0 +1,498 @@
/**
* @file Artifact 渲染器属性测试
* @description 使用 fast-check 进行属性测试,验证 Canvas 类型委托的正确性
* @module components/artifact/ArtifactRenderer.test
* @requirements 12.1, 12.2, 12.3, 12.5
*/
import { describe, test, expect } from "vitest";
import * as fc from "fast-check";
import { artifactRegistry } from "@/lib/artifact/registry";
import {
ALL_ARTIFACT_TYPES,
CANVAS_ARTIFACT_TYPES,
LIGHTWEIGHT_ARTIFACT_TYPES,
isCanvasType,
type ArtifactType,
type Artifact,
type ArtifactMeta,
} from "@/lib/artifact/types";
// ============================================================================
// 辅助函数
// ============================================================================
/**
* 创建 Artifact 对象的辅助函数
* @param type - Artifact 类型
* @param title - 标题
* @param content - 内容
* @param meta - 元数据
* @returns Artifact 对象
*/
function createArtifact(
type: ArtifactType,
title: string,
content: string,
meta: ArtifactMeta = {},
): Artifact {
return {
id: crypto.randomUUID(),
type,
title,
content,
status: "complete",
meta,
position: { start: 0, end: content.length },
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
/**
* 模拟 Canvas 委托函数
* 验证委托时内容和元数据是否被正确保留
* @param artifact - 要委托的 Artifact
* @returns 委托结果,包含保留的内容和元数据
*/
function delegateToCanvas(artifact: Artifact): {
content: string;
meta: ArtifactMeta;
canvasType: string;
} {
// 提取 canvas 子类型
const canvasType = artifact.type.replace("canvas:", "");
// 委托时应保留原始内容和元数据
return {
content: artifact.content,
meta: { ...artifact.meta },
canvasType,
};
}
// ============================================================================
// 自定义生成器 (Arbitraries)
// ============================================================================
/** Canvas 类型生成器 */
const canvasTypeArb = fc.constantFrom(...CANVAS_ARTIFACT_TYPES);
/** 轻量类型生成器 */
const lightweightTypeArb = fc.constantFrom(...LIGHTWEIGHT_ARTIFACT_TYPES);
/** 所有类型生成器 */
const allTypeArb = fc.constantFrom(...ALL_ARTIFACT_TYPES);
/** 安全标题生成器 */
const safeTitleArb = fc
.array(
fc.constantFrom(
..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-".split(
"",
),
),
{ minLength: 1, maxLength: 30 },
)
.map((chars) => chars.join(""));
/** 安全内容生成器 - 生成各种内容 */
const safeContentArb = fc.oneof(
fc.string({ minLength: 0, maxLength: 500 }),
fc.json(),
fc.constant(""),
fc.constant('{"type":"document","content":[]}'),
fc.constant("# Heading\n\nParagraph content"),
);
/** Canvas 平台生成器 */
const platformArb = fc.constantFrom(
"web",
"desktop",
"mobile",
"electron",
undefined,
);
/** Canvas 版本生成器 */
const versionArb = fc.oneof(
fc.constant("1.0.0"),
fc.constant("2.0.0"),
fc.constant("1.2.3"),
fc
.tuple(
fc.integer({ min: 0, max: 10 }),
fc.integer({ min: 0, max: 10 }),
fc.integer({ min: 0, max: 10 }),
)
.map(([major, minor, patch]) => `${major}.${minor}.${patch}`),
fc.constant(undefined),
);
/** Canvas 元数据生成器 */
const canvasMetaArb = fc
.record({
platform: platformArb,
version: versionArb,
author: fc.option(safeTitleArb, { nil: undefined }),
createdBy: fc.option(fc.constant("ai"), { nil: undefined }),
customField: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
nil: undefined,
}),
})
.map((meta) => {
// 过滤掉 undefined 值
const filtered: ArtifactMeta = {};
for (const [key, value] of Object.entries(meta)) {
if (value !== undefined) {
filtered[key] = value;
}
}
return filtered;
});
/** Canvas Artifact 生成器 */
const canvasArtifactArb = fc
.record({
type: canvasTypeArb,
title: safeTitleArb,
content: safeContentArb,
meta: canvasMetaArb,
})
.map(({ type, title, content, meta }) =>
createArtifact(type, title, content, meta),
);
/** 轻量 Artifact 生成器 */
const _lightweightArtifactArb = fc
.record({
type: lightweightTypeArb,
title: safeTitleArb,
content: safeContentArb,
})
.map(({ type, title, content }) => createArtifact(type, title, content, {}));
// ============================================================================
// Property 7: Canvas 类型委托正确性
// **Validates: Requirements 12.1, 12.2, 12.3, 12.5**
// ============================================================================
describe("Property 7: Canvas 类型委托正确性", () => {
/**
* **Validates: Requirements 12.1**
* 对于任何以 canvas: 开头的类型,isCanvasType 应返回 true
*/
test("canvas: 前缀的类型应被正确识别为 Canvas 类型", () => {
fc.assert(
fc.property(canvasTypeArb, (type) => {
// 验证类型以 canvas: 开头
expect(type.startsWith("canvas:")).toBe(true);
// 验证 isCanvasType 返回 true
expect(isCanvasType(type)).toBe(true);
// 验证 registry.isCanvasType 也返回 true
expect(artifactRegistry.isCanvasType(type)).toBe(true);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.1**
* 对于非 Canvas 类型,isCanvasType 应返回 false
*/
test("非 Canvas 类型应被正确识别", () => {
fc.assert(
fc.property(lightweightTypeArb, (type) => {
// 验证类型不以 canvas: 开头
expect(type.startsWith("canvas:")).toBe(false);
// 验证 isCanvasType 返回 false
expect(isCanvasType(type)).toBe(false);
// 验证 registry.isCanvasType 也返回 false
expect(artifactRegistry.isCanvasType(type)).toBe(false);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.2**
* Canvas 委托应保留 artifact 的原始内容
*/
test("Canvas 委托应保留 artifact 内容", () => {
fc.assert(
fc.property(canvasArtifactArb, (artifact) => {
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证内容被完整保留
expect(delegated.content).toBe(artifact.content);
// 验证内容长度一致
expect(delegated.content.length).toBe(artifact.content.length);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.3, 12.5**
* Canvas 委托应保留 artifact 的元数据
*/
test("Canvas 委托应保留 artifact 元数据", () => {
fc.assert(
fc.property(canvasArtifactArb, (artifact) => {
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证元数据被保留
for (const [key, value] of Object.entries(artifact.meta)) {
expect(delegated.meta[key]).toEqual(value);
}
// 验证元数据键数量一致
expect(Object.keys(delegated.meta).length).toBe(
Object.keys(artifact.meta).length,
);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.5**
* Canvas 特定元数据(platform、version)应被保留
*/
test("Canvas 特定元数据应被保留", () => {
fc.assert(
fc.property(
canvasTypeArb,
safeTitleArb,
safeContentArb,
platformArb,
versionArb,
(type, title, content, platform, version) => {
const meta: ArtifactMeta = {};
if (platform !== undefined) meta.platform = platform;
if (version !== undefined) meta.version = version;
const artifact = createArtifact(type, title, content, meta);
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证 platform 被保留
if (platform !== undefined) {
expect(delegated.meta.platform).toBe(platform);
}
// 验证 version 被保留
if (version !== undefined) {
expect(delegated.meta.version).toBe(version);
}
},
),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.1**
* Canvas 类型应正确提取子类型
*/
test("Canvas 类型应正确提取子类型", () => {
fc.assert(
fc.property(canvasArtifactArb, (artifact) => {
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证子类型被正确提取
const expectedCanvasType = artifact.type.replace("canvas:", "");
expect(delegated.canvasType).toBe(expectedCanvasType);
// 验证子类型是有效的 Canvas 子类型
const validCanvasSubtypes = [
"document",
"poster",
"music",
"script",
"novel",
];
expect(validCanvasSubtypes).toContain(delegated.canvasType);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.1, 12.2**
* 所有预定义的 Canvas 类型都应被正确处理
*/
test("所有预定义 Canvas 类型都应被正确处理", () => {
// 遍历所有 Canvas 类型
for (const canvasType of CANVAS_ARTIFACT_TYPES) {
// 验证是 Canvas 类型
expect(isCanvasType(canvasType)).toBe(true);
expect(artifactRegistry.isCanvasType(canvasType)).toBe(true);
// 创建测试 artifact
const artifact = createArtifact(
canvasType,
"Test Title",
"Test Content",
{ platform: "web", version: "1.0.0" },
);
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证内容和元数据被保留
expect(delegated.content).toBe(artifact.content);
expect(delegated.meta.platform).toBe("web");
expect(delegated.meta.version).toBe("1.0.0");
}
});
/**
* **Validates: Requirements 12.2**
* 空内容的 Canvas artifact 应被正确处理
*/
test("空内容的 Canvas artifact 应被正确处理", () => {
fc.assert(
fc.property(
canvasTypeArb,
safeTitleArb,
canvasMetaArb,
(type, title, meta) => {
const artifact = createArtifact(type, title, "", meta);
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证空内容被保留
expect(delegated.content).toBe("");
expect(delegated.content.length).toBe(0);
// 验证元数据仍被保留
for (const [key, value] of Object.entries(artifact.meta)) {
expect(delegated.meta[key]).toEqual(value);
}
},
),
{ numRuns: 50 },
);
});
/**
* **Validates: Requirements 12.2, 12.3**
* 大内容的 Canvas artifact 应被正确处理
*/
test("大内容的 Canvas artifact 应被正确处理", () => {
fc.assert(
fc.property(
canvasTypeArb,
safeTitleArb,
fc.string({ minLength: 1000, maxLength: 5000 }),
canvasMetaArb,
(type, title, largeContent, meta) => {
const artifact = createArtifact(type, title, largeContent, meta);
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证大内容被完整保留
expect(delegated.content).toBe(largeContent);
expect(delegated.content.length).toBe(largeContent.length);
},
),
{ numRuns: 20 },
);
});
/**
* **Validates: Requirements 12.5**
* 自定义元数据字段应被保留
*/
test("自定义元数据字段应被保留", () => {
fc.assert(
fc.property(
canvasTypeArb,
safeTitleArb,
safeContentArb,
fc.dictionary(
fc
.string({ minLength: 1, maxLength: 10 })
.filter((s) => /^[a-zA-Z][a-zA-Z0-9]*$/.test(s)),
fc.oneof(fc.string(), fc.integer(), fc.boolean()),
{ minKeys: 1, maxKeys: 5 },
),
(type, title, content, customMeta) => {
const artifact = createArtifact(type, title, content, customMeta);
// 执行委托
const delegated = delegateToCanvas(artifact);
// 验证所有自定义字段被保留
for (const [key, value] of Object.entries(customMeta)) {
expect(delegated.meta[key]).toEqual(value);
}
},
),
{ numRuns: 50 },
);
});
/**
* **Validates: Requirements 12.1**
* isCanvasType 和 registry.isCanvasType 应返回一致的结果
*/
test("isCanvasType 和 registry.isCanvasType 应返回一致的结果", () => {
fc.assert(
fc.property(allTypeArb, (type) => {
const fromTypes = isCanvasType(type);
const fromRegistry = artifactRegistry.isCanvasType(type);
// 两个函数应返回相同的结果
expect(fromTypes).toBe(fromRegistry);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 12.2, 12.3**
* 委托操作应是幂等的(多次委托结果一致)
*/
test("委托操作应是幂等的", () => {
fc.assert(
fc.property(canvasArtifactArb, (artifact) => {
// 第一次委托
const delegated1 = delegateToCanvas(artifact);
// 创建一个新的 artifact 使用委托后的内容和元数据
const newArtifact = createArtifact(
artifact.type,
artifact.title,
delegated1.content,
delegated1.meta,
);
// 第二次委托
const delegated2 = delegateToCanvas(newArtifact);
// 验证两次委托结果一致
expect(delegated2.content).toBe(delegated1.content);
expect(delegated2.canvasType).toBe(delegated1.canvasType);
// 验证元数据一致
for (const [key, value] of Object.entries(delegated1.meta)) {
expect(delegated2.meta[key]).toEqual(value);
}
}),
{ numRuns: 50 },
);
});
});
@@ -0,0 +1,371 @@
/**
* @file Artifact 统一渲染入口组件
* @description 根据 Artifact 类型分发到对应的渲染器,支持 Canvas 类型委托、错误边界和流式状态处理
* @module components/artifact/ArtifactRenderer
* @requirements 3.4, 11.1, 11.2, 11.3, 12.1, 14.2
*/
import React, {
Suspense,
memo,
useCallback,
useState,
useEffect,
useRef,
} from "react";
import { Loader2, AlertTriangle, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { artifactRegistry } from "@/lib/artifact/registry";
import { useDebouncedValue } from "@/lib/artifact/hooks";
import { ErrorFallbackRenderer } from "./ErrorFallbackRenderer";
import { CanvasAdapter } from "./CanvasAdapter";
import type { Artifact, ArtifactRendererProps } from "@/lib/artifact/types";
// ============================================================================
// 错误边界组件
// ============================================================================
/**
* 错误边界状态
*/
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* 错误边界 Props
*/
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
/**
* Artifact 错误边界组件
* 捕获渲染器中的错误,显示友好的错误信息
*
* @requirements 14.2
*/
class ArtifactErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error("[ArtifactRenderer] 渲染错误:", error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render(): React.ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// ============================================================================
// 辅助组件
// ============================================================================
/**
* 渲染器加载骨架屏
*/
const RendererSkeleton: React.FC = memo(() => (
<div className="flex items-center justify-center h-full min-h-[200px] bg-[#1e2227]">
<div className="flex flex-col items-center gap-3 text-gray-400">
<Loader2 className="w-8 h-8 animate-spin" />
<span className="text-sm">加载渲染器...</span>
</div>
</div>
));
RendererSkeleton.displayName = "RendererSkeleton";
/**
* 流式状态指示器 Props
* @requirements 11.1
*/
interface StreamingIndicatorProps {
/** 是否正在完成(用于平滑过渡) */
isCompleting?: boolean;
}
/**
* 流式状态指示器
* 显示正在生成的视觉指示,支持平滑过渡到完成状态
*
* @requirements 11.1, 11.3
*/
const StreamingIndicator: React.FC<StreamingIndicatorProps> = memo(
({ isCompleting = false }) => (
<div
className={cn(
"absolute bottom-3 right-3 flex items-center gap-2 px-3 py-1.5 rounded-full transition-all duration-300",
isCompleting ? "bg-green-500/20 scale-95 opacity-80" : "bg-blue-500/20",
)}
>
{isCompleting ? (
<>
<div className="w-4 h-4 text-green-400">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span className="text-xs text-green-400">完成</span>
</>
) : (
<>
<Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
<span className="text-xs text-blue-400">生成中...</span>
{/* 进度动画条 */}
<div className="w-12 h-1 bg-blue-500/30 rounded-full overflow-hidden">
<div className="h-full bg-blue-400 rounded-full animate-streaming-progress" />
</div>
</>
)}
</div>
),
);
StreamingIndicator.displayName = "StreamingIndicator";
/**
* 未知类型回退渲染器
* 当 Artifact 类型没有对应的渲染器时显示
*/
const FallbackRenderer: React.FC<{ artifact: Artifact }> = memo(
({ artifact }) => (
<div className="flex flex-col h-full bg-[#1e2227]">
{/* 提示区域 */}
<div className="p-4 bg-yellow-500/10 border-b border-yellow-500/20">
<div className="flex items-center gap-2 text-yellow-400 font-medium mb-2">
<AlertTriangle className="w-5 h-5" />
<span>未知类型</span>
</div>
<div className="text-sm text-gray-400">
类型 "{artifact.type}" 没有对应的渲染器,显示原始内容。
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-auto p-4">
<pre className="p-3 bg-black/30 rounded text-sm text-gray-300 overflow-auto whitespace-pre-wrap">
{artifact.content}
</pre>
</div>
</div>
),
);
FallbackRenderer.displayName = "FallbackRenderer";
// ============================================================================
// 主组件
// ============================================================================
/**
* ArtifactRenderer Props
*/
export interface ArtifactRendererComponentProps extends ArtifactRendererProps {
/** 自定义类名 */
className?: string;
/** 防抖延迟(毫秒),默认 100ms */
debounceDelay?: number;
}
/**
* Artifact 统一渲染入口组件
*
* 功能特性:
* - 根据 Artifact 类型分发到对应的渲染器 (Requirement 3.4)
* - Canvas 类型委托给 Canvas 系统处理 (Requirement 12.1)
* - 错误边界捕获渲染错误,显示友好的错误信息 (Requirement 14.2)
* - 流式指示器显示正在生成的视觉指示 (Requirement 11.1)
* - 防抖更新避免频繁重渲染 (Requirement 11.2)
* - 流式完成时平滑过渡到完成状态 (Requirement 11.3)
* - 使用 React.Suspense 支持懒加载渲染器
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
* @param onContentChange - 内容变更回调
* @param className - 自定义类名
* @param debounceDelay - 防抖延迟(毫秒)
*
* @requirements 3.4, 11.1, 11.2, 11.3, 12.1, 14.2
*/
export const ArtifactRenderer: React.FC<ArtifactRendererComponentProps> = memo(
({
artifact,
isStreaming = false,
onContentChange,
className,
debounceDelay = 100,
}) => {
// 错误状态管理
const [renderError, setRenderError] = useState<Error | null>(null);
const [showSourceOnError, setShowSourceOnError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
// 流式完成过渡状态
// @requirements 11.3
const [isCompleting, setIsCompleting] = useState(false);
const prevStreamingRef = useRef(isStreaming);
// 防抖处理内容更新,避免频繁重渲染
// @requirements 11.2
const debouncedContent = useDebouncedValue(artifact.content, debounceDelay);
// 创建带防抖内容的 artifact 副本
const debouncedArtifact: Artifact = {
...artifact,
content: isStreaming ? debouncedContent : artifact.content,
};
// 监听流式状态变化,实现平滑过渡
// @requirements 11.3
useEffect(() => {
// 从流式状态变为非流式状态时,显示完成动画
if (prevStreamingRef.current && !isStreaming) {
setIsCompleting(true);
// 500ms 后隐藏完成指示器
const timer = setTimeout(() => {
setIsCompleting(false);
}, 500);
return () => clearTimeout(timer);
}
prevStreamingRef.current = isStreaming;
}, [isStreaming]);
/**
* 处理渲染错误
*/
const handleError = useCallback((error: Error) => {
setRenderError(error);
}, []);
/**
* 重试渲染
*/
const handleRetry = useCallback(() => {
setRenderError(null);
setShowSourceOnError(false);
setRetryKey((k) => k + 1);
}, []);
/**
* 显示源码
*/
const handleShowSource = useCallback(() => {
setShowSourceOnError(true);
}, []);
// 获取渲染器注册项
const entry = artifactRegistry.get(artifact.type);
// 如果有错误且选择显示源码,直接显示源码
if (renderError && showSourceOnError) {
return (
<div className={cn("relative h-full", className)}>
<div className="flex flex-col h-full bg-[#1e2227]">
<div className="p-2 bg-yellow-500/10 border-b border-yellow-500/20 flex items-center justify-between">
<span className="text-sm text-yellow-400">
源码视图(渲染失败)
</span>
<button
onClick={handleRetry}
className="flex items-center gap-1 px-2 py-1 text-xs bg-white/10 hover:bg-white/15 rounded"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
<div className="flex-1 overflow-auto p-4">
<pre className="text-sm text-gray-300 whitespace-pre-wrap">
{artifact.content}
</pre>
</div>
</div>
</div>
);
}
// 未注册的类型,使用回退渲染器
if (!entry) {
return (
<div className={cn("relative h-full", className)}>
<FallbackRenderer artifact={debouncedArtifact} />
{(isStreaming || isCompleting) && (
<StreamingIndicator isCompleting={isCompleting} />
)}
</div>
);
}
// Canvas 类型委托给 Canvas 系统
// @requirements 12.1
if (artifactRegistry.isCanvasType(artifact.type)) {
return (
<div className={cn("relative h-full", className)}>
<CanvasAdapter
artifact={debouncedArtifact}
isStreaming={isStreaming}
onContentChange={onContentChange}
/>
{(isStreaming || isCompleting) && (
<StreamingIndicator isCompleting={isCompleting} />
)}
</div>
);
}
// 获取渲染器组件
const RendererComponent = entry.component;
// 错误回退组件
const errorFallback = (
<ErrorFallbackRenderer
artifact={debouncedArtifact}
error={renderError}
onRetry={handleRetry}
onShowSource={handleShowSource}
/>
);
return (
<div className={cn("relative h-full", className)} key={retryKey}>
<Suspense fallback={<RendererSkeleton />}>
<ArtifactErrorBoundary fallback={errorFallback} onError={handleError}>
<RendererComponent
artifact={debouncedArtifact}
isStreaming={isStreaming}
onContentChange={onContentChange}
/>
</ArtifactErrorBoundary>
</Suspense>
{(isStreaming || isCompleting) && (
<StreamingIndicator isCompleting={isCompleting} />
)}
</div>
);
},
);
ArtifactRenderer.displayName = "ArtifactRenderer";
// 导出错误边界组件供外部使用
export { ArtifactErrorBoundary };
export default ArtifactRenderer;
@@ -0,0 +1,576 @@
/**
* @file Artifact 工具栏属性测试
* @description 使用 fast-check 进行属性测试,验证工具栏文件扩展名的正确性
* @module components/artifact/ArtifactToolbar.test
* @requirements 13.2
*/
import { describe, test, expect } from "vitest";
import * as fc from "fast-check";
import { artifactRegistry, ArtifactRegistry } from "@/lib/artifact/registry";
import {
ALL_ARTIFACT_TYPES,
LIGHTWEIGHT_ARTIFACT_TYPES,
CANVAS_ARTIFACT_TYPES,
DEFAULT_FILE_EXTENSIONS,
type ArtifactType,
type Artifact,
} from "@/lib/artifact/types";
// ============================================================================
// 辅助函数(从 ArtifactToolbar.tsx 复制,用于测试)
// ============================================================================
/**
* 根据语言获取文件扩展名
* @param language - 编程语言
* @returns 文件扩展名
*/
function getLanguageExtension(language: string): string {
const langExtMap: Record<string, string> = {
javascript: "js",
typescript: "ts",
python: "py",
rust: "rs",
go: "go",
java: "java",
cpp: "cpp",
c: "c",
csharp: "cs",
ruby: "rb",
php: "php",
swift: "swift",
kotlin: "kt",
scala: "scala",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
yaml: "yaml",
yml: "yml",
xml: "xml",
markdown: "md",
sql: "sql",
shell: "sh",
bash: "sh",
powershell: "ps1",
dockerfile: "dockerfile",
tsx: "tsx",
jsx: "jsx",
vue: "vue",
svelte: "svelte",
};
const lower = language.toLowerCase();
// 使用 in 操作符检查,避免原型链上的属性
return lower in langExtMap ? langExtMap[lower] : "txt";
}
/**
* 清理文件名,移除非法字符
* @param name - 原始文件名
* @returns 清理后的文件名
*/
function sanitizeFilename(name: string): string {
const sanitized = name
.replace(/[<>:"/\\|?*]/g, "_")
.replace(/\s+/g, "_")
.trim();
return sanitized || "artifact";
}
/**
* 根据 Artifact 类型和元数据生成文件名
* @param artifact - Artifact 对象
* @param registry - 注册表实例
* @returns 文件名
*/
function generateFilename(
artifact: Artifact,
registry: ArtifactRegistry,
): string {
if (artifact.meta.filename) {
return artifact.meta.filename;
}
const ext = registry.getFileExtension(artifact.type);
if (artifact.type === "code" && artifact.meta.language) {
const langExt = getLanguageExtension(artifact.meta.language);
return `${sanitizeFilename(artifact.title)}.${langExt}`;
}
return `${sanitizeFilename(artifact.title)}.${ext}`;
}
// ============================================================================
// 自定义生成器 (Arbitraries)
// ============================================================================
/** Artifact 类型生成器 */
const artifactTypeArb = fc.constantFrom(...ALL_ARTIFACT_TYPES);
/** 轻量类型生成器 */
const lightweightTypeArb = fc.constantFrom(...LIGHTWEIGHT_ARTIFACT_TYPES);
/** Canvas 类型生成器 */
const canvasTypeArb = fc.constantFrom(...CANVAS_ARTIFACT_TYPES);
/** 支持的编程语言生成器 */
const supportedLanguageArb = fc.constantFrom(
"javascript",
"typescript",
"python",
"rust",
"go",
"java",
"cpp",
"c",
"csharp",
"ruby",
"php",
"swift",
"kotlin",
"scala",
"html",
"css",
"scss",
"less",
"json",
"yaml",
"yml",
"xml",
"markdown",
"sql",
"shell",
"bash",
"powershell",
"dockerfile",
"tsx",
"jsx",
"vue",
"svelte",
);
/** 未知语言生成器 */
const unknownLanguageArb = fc
.string({ minLength: 1, maxLength: 20 })
.filter(
(s) =>
![
"javascript",
"typescript",
"python",
"rust",
"go",
"java",
"cpp",
"c",
"csharp",
"ruby",
"php",
"swift",
"kotlin",
"scala",
"html",
"css",
"scss",
"less",
"json",
"yaml",
"yml",
"xml",
"markdown",
"sql",
"shell",
"bash",
"powershell",
"dockerfile",
"tsx",
"jsx",
"vue",
"svelte",
].includes(s.toLowerCase()),
);
/** 安全标题生成器 */
const safeTitleArb = fc
.array(
fc.constantFrom(
..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-".split(
"",
),
),
{ minLength: 1, maxLength: 20 },
)
.map((chars) => chars.join(""));
/** 安全内容生成器 */
const safeContentArb = fc.string({ minLength: 0, maxLength: 100 });
/** 创建 Artifact 对象的辅助函数 */
function createArtifact(
type: ArtifactType,
title: string,
content: string,
meta: Record<string, unknown> = {},
): Artifact {
return {
id: crypto.randomUUID(),
type,
title,
content,
status: "complete",
meta,
position: { start: 0, end: 0 },
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
/** Artifact 生成器 */
const artifactArb = fc
.record({
type: artifactTypeArb,
title: safeTitleArb,
content: safeContentArb,
language: fc.option(supportedLanguageArb, { nil: undefined }),
})
.map(({ type, title, content, language }) =>
createArtifact(type, title, content, language ? { language } : {}),
);
/** 带语言的代码 Artifact 生成器 */
const codeArtifactWithLanguageArb = fc
.record({
title: safeTitleArb,
content: safeContentArb,
language: supportedLanguageArb,
})
.map(({ title, content, language }) =>
createArtifact("code", title, content, { language }),
);
// ============================================================================
// 有效扩展名定义
// ============================================================================
/** 各类型的有效文件扩展名 */
const VALID_EXTENSIONS: Record<ArtifactType, string[]> = {
code: [
"txt",
"js",
"ts",
"py",
"rs",
"go",
"java",
"cpp",
"c",
"cs",
"rb",
"php",
"swift",
"kt",
"scala",
"html",
"css",
"scss",
"less",
"json",
"yaml",
"yml",
"xml",
"md",
"sql",
"sh",
"ps1",
"dockerfile",
"tsx",
"jsx",
"vue",
"svelte",
],
html: ["html"],
svg: ["svg"],
mermaid: ["mmd"],
react: ["jsx"],
"canvas:document": ["md"],
"canvas:poster": ["json"],
"canvas:music": ["json"],
"canvas:script": ["json"],
"canvas:novel": ["json"],
};
/** 语言到扩展名的映射 */
const LANGUAGE_EXTENSION_MAP: Record<string, string> = {
javascript: "js",
typescript: "ts",
python: "py",
rust: "rs",
go: "go",
java: "java",
cpp: "cpp",
c: "c",
csharp: "cs",
ruby: "rb",
php: "php",
swift: "swift",
kotlin: "kt",
scala: "scala",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
yaml: "yaml",
yml: "yml",
xml: "xml",
markdown: "md",
sql: "sql",
shell: "sh",
bash: "sh",
powershell: "ps1",
dockerfile: "dockerfile",
tsx: "tsx",
jsx: "jsx",
vue: "vue",
svelte: "svelte",
};
// ============================================================================
// Property 10: 工具栏文件扩展名正确性
// **Validates: Requirements 13.2**
// ============================================================================
describe("Property 10: 工具栏文件扩展名正确性", () => {
/**
* **Validates: Requirements 13.2**
* 对于任何 Artifact 类型,getFileExtension 应返回该类型的有效扩展名
*/
test("getFileExtension 应返回有效的文件扩展名", () => {
fc.assert(
fc.property(artifactTypeArb, (type) => {
const ext = artifactRegistry.getFileExtension(type);
// 扩展名应该是非空字符串
expect(typeof ext).toBe("string");
expect(ext.length).toBeGreaterThan(0);
// 扩展名应该在该类型的有效扩展名列表中
expect(VALID_EXTENSIONS[type]).toContain(ext);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 对于代码类型,根据语言应返回正确的扩展名
*/
test("代码类型应根据语言返回正确的扩展名", () => {
fc.assert(
fc.property(supportedLanguageArb, (language) => {
const ext = getLanguageExtension(language);
// 扩展名应该是非空字符串
expect(typeof ext).toBe("string");
expect(ext.length).toBeGreaterThan(0);
// 扩展名应该与预期映射一致
const expectedExt = LANGUAGE_EXTENSION_MAP[language.toLowerCase()];
expect(ext).toBe(expectedExt);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 对于未知语言,应返回默认扩展名 'txt'
*/
test("未知语言应返回默认扩展名 txt", () => {
fc.assert(
fc.property(unknownLanguageArb, (language) => {
const ext = getLanguageExtension(language);
expect(ext).toBe("txt");
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 生成的文件名应包含正确的扩展名
*/
test("生成的文件名应包含正确的扩展名", () => {
fc.assert(
fc.property(artifactArb, (artifact) => {
const registry = new ArtifactRegistry();
const filename = generateFilename(artifact, registry);
// 文件名应该是非空字符串
expect(typeof filename).toBe("string");
expect(filename.length).toBeGreaterThan(0);
// 文件名应该包含扩展名
expect(filename).toContain(".");
// 提取扩展名
const ext = filename.split(".").pop() || "";
// 扩展名应该在该类型的有效扩展名列表中
expect(VALID_EXTENSIONS[artifact.type]).toContain(ext);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 带语言的代码 Artifact 应使用语言对应的扩展名
*/
test("带语言的代码 Artifact 应使用语言对应的扩展名", () => {
fc.assert(
fc.property(codeArtifactWithLanguageArb, (artifact) => {
const registry = new ArtifactRegistry();
const filename = generateFilename(artifact, registry);
// 提取扩展名
const ext = filename.split(".").pop() || "";
// 扩展名应该与语言对应
const language = artifact.meta.language as string;
const expectedExt = LANGUAGE_EXTENSION_MAP[language.toLowerCase()];
expect(ext).toBe(expectedExt);
}),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 轻量类型应返回预定义的默认扩展名
*/
test("轻量类型应返回预定义的默认扩展名", () => {
fc.assert(
fc.property(lightweightTypeArb, (type) => {
const ext = artifactRegistry.getFileExtension(type);
const expectedExt = DEFAULT_FILE_EXTENSIONS[type];
expect(ext).toBe(expectedExt);
}),
{ numRuns: 50 },
);
});
/**
* **Validates: Requirements 13.2**
* Canvas 类型应返回预定义的默认扩展名
*/
test("Canvas 类型应返回预定义的默认扩展名", () => {
fc.assert(
fc.property(canvasTypeArb, (type) => {
const ext = artifactRegistry.getFileExtension(type);
const expectedExt = DEFAULT_FILE_EXTENSIONS[type];
expect(ext).toBe(expectedExt);
}),
{ numRuns: 50 },
);
});
/**
* **Validates: Requirements 13.2**
* 语言扩展名映射应该是大小写不敏感的
*/
test("语言扩展名映射应该是大小写不敏感的", () => {
fc.assert(
fc.property(
supportedLanguageArb,
fc.constantFrom("lower", "upper", "mixed"),
(language, caseType) => {
let transformedLang: string;
switch (caseType) {
case "lower":
transformedLang = language.toLowerCase();
break;
case "upper":
transformedLang = language.toUpperCase();
break;
case "mixed":
transformedLang = language
.split("")
.map((c, i) =>
i % 2 === 0 ? c.toUpperCase() : c.toLowerCase(),
)
.join("");
break;
default:
transformedLang = language;
}
const ext = getLanguageExtension(transformedLang);
const expectedExt = LANGUAGE_EXTENSION_MAP[language.toLowerCase()];
expect(ext).toBe(expectedExt);
},
),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 如果 Artifact 有自定义文件名,应直接使用
*/
test("自定义文件名应被直接使用", () => {
fc.assert(
fc.property(
artifactTypeArb,
safeTitleArb,
safeContentArb,
fc.string({ minLength: 1, maxLength: 20 }).map((s) => `${s}.custom`),
(type, title, content, customFilename) => {
const artifact = createArtifact(type, title, content, {
filename: customFilename,
});
const registry = new ArtifactRegistry();
const filename = generateFilename(artifact, registry);
expect(filename).toBe(customFilename);
},
),
{ numRuns: 100 },
);
});
/**
* **Validates: Requirements 13.2**
* 文件名应该被正确清理(移除非法字符)
*/
test("文件名应该被正确清理", () => {
fc.assert(
fc.property(
artifactTypeArb,
fc.string({ minLength: 1, maxLength: 30 }),
safeContentArb,
(type, rawTitle, content) => {
const artifact = createArtifact(type, rawTitle, content);
const registry = new ArtifactRegistry();
const filename = generateFilename(artifact, registry);
// 文件名不应包含非法字符
const illegalChars = ["<", ">", ":", '"', "/", "\\", "|", "?", "*"];
for (const char of illegalChars) {
// 扩展名部分可能包含这些字符,所以只检查文件名部分
const nameWithoutExt = filename.substring(
0,
filename.lastIndexOf("."),
);
expect(nameWithoutExt).not.toContain(char);
}
},
),
{ numRuns: 100 },
);
});
});
+385
View File
@@ -0,0 +1,385 @@
/**
* @file Artifact 工具栏组件
* @description 提供 Artifact 的快捷操作:复制、下载、源码切换、新窗口打开、关闭
* @module components/artifact/ArtifactToolbar
* @requirements 13.1, 13.2, 13.3, 13.4, 13.5, 13.6
*/
import React, { useState, useCallback, memo } from "react";
import {
Copy,
Check,
Download,
Code,
Eye,
ExternalLink,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { artifactRegistry } from "@/lib/artifact/registry";
import type { Artifact } from "@/lib/artifact/types";
/**
* 工具栏按钮组件 Props
*/
interface ToolbarButtonProps {
onClick: () => void;
title: string;
disabled?: boolean;
active?: boolean;
children: React.ReactNode;
}
/**
* 工具栏按钮组件
* 统一的按钮样式
*/
const ToolbarButton: React.FC<ToolbarButtonProps> = memo(
({ onClick, title, disabled = false, active = false, children }) => (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
"flex items-center justify-center w-7 h-7 rounded transition-all",
"hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed",
active ? "bg-white/15 text-white" : "text-gray-400 hover:text-white",
)}
>
{children}
</button>
),
);
ToolbarButton.displayName = "ToolbarButton";
/**
* ArtifactToolbar Props
*/
export interface ArtifactToolbarProps {
/** 要操作的 Artifact 对象 */
artifact: Artifact;
/** 当前是否显示源码视图 */
showSource?: boolean;
/** 源码切换回调 */
onToggleSource?: () => void;
/** 关闭回调 */
onClose?: () => void;
}
/**
* 下载 Blob 文件的辅助函数
* @param blob - 要下载的 Blob 对象
* @param filename - 文件名
*/
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* HTML 转义函数
* @param str - 要转义的字符串
* @returns 转义后的字符串
*/
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
/**
* 根据 Artifact 类型和元数据生成文件名
* @param artifact - Artifact 对象
* @returns 文件名
*/
function generateFilename(artifact: Artifact): string {
// 如果元数据中有文件名,优先使用
if (artifact.meta.filename) {
return artifact.meta.filename;
}
// 获取文件扩展名
const ext = artifactRegistry.getFileExtension(artifact.type);
// 对于代码类型,根据语言选择扩展名
if (artifact.type === "code" && artifact.meta.language) {
const langExt = getLanguageExtension(artifact.meta.language);
return `${sanitizeFilename(artifact.title)}.${langExt}`;
}
return `${sanitizeFilename(artifact.title)}.${ext}`;
}
/**
* 根据语言获取文件扩展名
* @param language - 编程语言
* @returns 文件扩展名
*/
function getLanguageExtension(language: string): string {
const langExtMap: Record<string, string> = {
javascript: "js",
typescript: "ts",
python: "py",
rust: "rs",
go: "go",
java: "java",
cpp: "cpp",
c: "c",
csharp: "cs",
ruby: "rb",
php: "php",
swift: "swift",
kotlin: "kt",
scala: "scala",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
yaml: "yaml",
yml: "yml",
xml: "xml",
markdown: "md",
sql: "sql",
shell: "sh",
bash: "sh",
powershell: "ps1",
dockerfile: "dockerfile",
tsx: "tsx",
jsx: "jsx",
vue: "vue",
svelte: "svelte",
};
const lower = language.toLowerCase();
// 使用 in 操作符检查,避免原型链上的属性
return lower in langExtMap ? langExtMap[lower] : "txt";
}
/**
* 清理文件名,移除非法字符
* @param name - 原始文件名
* @returns 清理后的文件名
*/
function sanitizeFilename(name: string): string {
// 移除或替换非法字符
const sanitized = name
.replace(/[<>:"/\\|?*]/g, "_")
.replace(/\s+/g, "_")
.trim();
// 如果为空,使用默认名称
return sanitized || "artifact";
}
/**
* Artifact 工具栏组件
*
* 功能特性:
* - 复制内容到剪贴板 (Requirement 13.1)
* - 下载文件,根据类型选择正确的扩展名 (Requirement 13.2)
* - 源码/预览视图切换 (Requirement 13.3)
* - 在新窗口中打开 (Requirement 13.4)
* - 关闭按钮 (Requirement 13.5)
* - 紧凑的水平布局 (Requirement 13.6)
*
* @param artifact - 要操作的 Artifact 对象
* @param showSource - 当前是否显示源码视图
* @param onToggleSource - 源码切换回调
* @param onClose - 关闭回调
*/
export const ArtifactToolbar: React.FC<ArtifactToolbarProps> = memo(
({ artifact, showSource = false, onToggleSource, onClose }) => {
const [copied, setCopied] = useState(false);
// 获取渲染器信息
const entry = artifactRegistry.get(artifact.type);
/**
* 复制内容到剪贴板
* @requirements 13.1
*/
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(artifact.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("复制内容失败:", err);
}
}, [artifact.content]);
/**
* 下载文件
* @requirements 13.2
*/
const handleDownload = useCallback(() => {
const filename = generateFilename(artifact);
const mimeType = getMimeType(artifact.type);
const blob = new Blob([artifact.content], { type: mimeType });
downloadBlob(blob, filename);
}, [artifact]);
/**
* 在新窗口中打开
* @requirements 13.4
*/
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>
<head>
<title>${escapeHtml(artifact.title)}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a1a; }
svg { max-width: 100%; max-height: 100vh; }
</style>
</head>
<body>${artifact.content}</body>
</html>
`);
} else {
// 其他类型显示为预格式化文本
win.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(artifact.title)}</title>
<style>
body { margin: 0; padding: 16px; background: #1e1e1e; color: #d4d4d4; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; }
</style>
</head>
<body><pre>${escapeHtml(artifact.content)}</pre></body>
</html>
`);
}
win.document.close();
}
}, [artifact]);
/**
* 切换源码视图
* @requirements 13.3
*/
const handleToggleSource = useCallback(() => {
onToggleSource?.();
}, [onToggleSource]);
/**
* 关闭面板
* @requirements 13.5
*/
const handleClose = useCallback(() => {
onClose?.();
}, [onClose]);
// 判断是否支持源码切换(非代码类型才需要切换)
const supportsSourceToggle = artifact.type !== "code" && onToggleSource;
return (
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-white/10 bg-[#21252b]">
{/* 标题区域 */}
<div className="flex-1 flex items-center gap-2 min-w-0">
{/* 类型图标 */}
{entry && (
<span className="text-gray-400 text-xs shrink-0">
{entry.displayName}
</span>
)}
{/* 标题 */}
<span className="text-sm font-medium text-white truncate">
{artifact.title}
</span>
</div>
{/* 操作按钮区域 */}
<div className="flex items-center gap-0.5 shrink-0">
{/* 复制按钮 */}
<ToolbarButton
onClick={handleCopy}
title={copied ? "已复制" : "复制内容"}
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4" />
)}
</ToolbarButton>
{/* 下载按钮 */}
<ToolbarButton onClick={handleDownload} title="下载文件">
<Download className="w-4 h-4" />
</ToolbarButton>
{/* 源码切换按钮 */}
{supportsSourceToggle && (
<ToolbarButton
onClick={handleToggleSource}
title={showSource ? "显示预览" : "显示源码"}
active={showSource}
>
{showSource ? (
<Eye className="w-4 h-4" />
) : (
<Code className="w-4 h-4" />
)}
</ToolbarButton>
)}
{/* 新窗口打开按钮 */}
<ToolbarButton onClick={handleOpenInWindow} title="在新窗口中打开">
<ExternalLink className="w-4 h-4" />
</ToolbarButton>
{/* 关闭按钮 */}
{onClose && (
<ToolbarButton onClick={handleClose} title="关闭">
<X className="w-4 h-4" />
</ToolbarButton>
)}
</div>
</div>
);
},
);
ArtifactToolbar.displayName = "ArtifactToolbar";
/**
* 根据 Artifact 类型获取 MIME 类型
* @param type - Artifact 类型
* @returns MIME 类型
*/
function getMimeType(type: Artifact["type"]): string {
const mimeTypes: Record<string, string> = {
code: "text/plain",
html: "text/html",
svg: "image/svg+xml",
mermaid: "text/plain",
react: "text/javascript",
"canvas:document": "text/markdown",
"canvas:poster": "application/json",
"canvas:music": "application/json",
"canvas:script": "application/json",
"canvas:novel": "application/json",
};
return mimeTypes[type] || "text/plain";
}
export default ArtifactToolbar;
+233
View File
@@ -0,0 +1,233 @@
/**
* @file Canvas 适配器组件
* @description 将 Canvas 类型的 Artifact 适配到现有 Canvas 系统
* @module components/artifact/CanvasAdapter
* @requirements 12.1, 12.2, 12.3, 12.4, 12.5
*/
import React, { memo, useCallback, useMemo, useState, useEffect } from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Artifact } from "@/lib/artifact/types";
// Canvas 系统导入
import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory";
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
// 工具函数导入
import {
getCanvasTypeFromArtifact,
createCanvasStateFromArtifact,
extractContentFromCanvasState,
CANVAS_TYPE_LABELS,
CANVAS_TYPE_ICONS,
} from "./canvasAdapterUtils";
// ============================================================================
// 类型定义
// ============================================================================
/**
* Canvas 适配器 Props
*/
export interface CanvasAdapterProps {
/** 要渲染的 Artifact 对象 */
artifact: Artifact;
/** 是否处于流式生成状态 */
isStreaming?: boolean;
/** 内容变更回调 */
onContentChange?: (content: string) => void;
/** 自定义类名 */
className?: string;
}
// ============================================================================
// 辅助组件
// ============================================================================
/**
* Canvas 加载骨架屏
*/
const CanvasLoadingSkeleton: React.FC = memo(() => (
<div className="flex items-center justify-center h-full min-h-[300px] bg-[#1e2227]">
<div className="flex flex-col items-center gap-3 text-gray-400">
<Loader2 className="w-8 h-8 animate-spin" />
<span className="text-sm">加载 Canvas...</span>
</div>
</div>
));
CanvasLoadingSkeleton.displayName = "CanvasLoadingSkeleton";
/**
* Canvas 不支持提示
*/
const CanvasUnsupportedMessage: React.FC<{ canvasType: string }> = memo(
({ canvasType }) => (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] bg-[#1e2227]">
<div className="text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span className="text-2xl">⚠️</span>
</div>
<h3 className="text-lg font-medium text-white mb-2">
不支持的 Canvas 类型
</h3>
<p className="text-sm text-gray-400">
类型 "{canvasType}" 暂不支持在此处渲染
</p>
</div>
</div>
),
);
CanvasUnsupportedMessage.displayName = "CanvasUnsupportedMessage";
// ============================================================================
// 主组件
// ============================================================================
/**
* Canvas 适配器组件
*
* 功能特性:
* - 检测 Canvas 类型 (canvas:document, canvas:poster 等) (Requirement 12.1)
* - 将 Artifact 内容作为初始状态传递给 Canvas (Requirement 12.2)
* - 同步 Canvas 状态变更回 Artifact (Requirement 12.3)
* - 支持在完整 Canvas 编辑器模式中打开 (Requirement 12.4)
* - 保留 Canvas 特定元数据 (platform, version 等) (Requirement 12.5)
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
* @param onContentChange - 内容变更回调
* @param className - 自定义类名
*
* @requirements 12.1, 12.2, 12.3, 12.4, 12.5
*/
export const CanvasAdapter: React.FC<CanvasAdapterProps> = memo(
({ artifact, isStreaming = false, onContentChange, className }) => {
// 获取 Canvas 类型
const canvasType = useMemo(
() => getCanvasTypeFromArtifact(artifact.type),
[artifact.type],
);
// Canvas 状态管理
// @requirements 12.2
const [canvasState, setCanvasState] = useState<CanvasStateUnion | null>(
() => createCanvasStateFromArtifact(artifact),
);
// 是否显示完整编辑器模式
const [isFullEditorMode, setIsFullEditorMode] = useState(false);
// 当 Artifact 内容变化时,更新 Canvas 状态
// @requirements 12.2
useEffect(() => {
// 仅在非编辑模式下同步外部内容变化
if (!isFullEditorMode) {
const newState = createCanvasStateFromArtifact(artifact);
if (newState) {
setCanvasState(newState);
}
}
}, [artifact, isFullEditorMode]);
/**
* 处理 Canvas 状态变更
* 同步状态变更回 Artifact
* @requirements 12.3
*/
const handleStateChange = useCallback(
(newState: CanvasStateUnion) => {
setCanvasState(newState);
// 提取内容并回调
if (onContentChange) {
const content = extractContentFromCanvasState(newState);
onContentChange(content);
}
},
[onContentChange],
);
/**
* 处理关闭 Canvas
*/
const handleClose = useCallback(() => {
setIsFullEditorMode(false);
}, []);
/**
* 打开完整编辑器模式
* @requirements 12.4
*/
const handleOpenFullEditor = useCallback(() => {
setIsFullEditorMode(true);
console.log("[CanvasAdapter] 打开完整 Canvas 编辑器:", artifact.type);
}, [artifact.type]);
// 不支持的 Canvas 类型
if (!canvasType) {
return (
<div className={cn("h-full", className)}>
<CanvasUnsupportedMessage canvasType={artifact.type} />
</div>
);
}
// Canvas 状态未初始化
if (!canvasState) {
return (
<div className={cn("h-full", className)}>
<CanvasLoadingSkeleton />
</div>
);
}
// 获取显示信息
const label =
CANVAS_TYPE_LABELS[canvasType as keyof typeof CANVAS_TYPE_LABELS];
const icon =
CANVAS_TYPE_ICONS[canvasType as keyof typeof CANVAS_TYPE_ICONS];
return (
<div className={cn("h-full flex flex-col bg-[#1e2227]", className)}>
{/* Canvas 信息头部 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-700">
<div className="flex items-center gap-2">
<span className="text-lg">{icon}</span>
<span className="text-sm font-medium text-white">
{label} Canvas
</span>
{isStreaming && (
<span className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 rounded">
生成中...
</span>
)}
</div>
<button
onClick={handleOpenFullEditor}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors"
title="在完整编辑器中打开"
>
<ExternalLink className="w-4 h-4" />
<span>编辑</span>
</button>
</div>
{/* Canvas 渲染区域 */}
<div className="flex-1 overflow-hidden">
<CanvasFactory
theme="general"
state={canvasState}
onStateChange={handleStateChange}
onClose={handleClose}
isStreaming={isStreaming}
/>
</div>
</div>
);
},
);
CanvasAdapter.displayName = "CanvasAdapter";
export default CanvasAdapter;
@@ -0,0 +1,319 @@
/**
* @file Artifact 错误回退渲染器
* @description 当 Artifact 渲染失败时显示友好的错误信息,提供重试和源码回退功能
* @module components/artifact/ErrorFallbackRenderer
* @requirements 14.1, 14.2, 14.3, 14.5
*/
import React, { memo, useState, useCallback } from "react";
import {
AlertTriangle,
RefreshCw,
Code,
Copy,
Check,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { Artifact } from "@/lib/artifact/types";
// ============================================================================
// 类型定义
// ============================================================================
/**
* ErrorFallbackRenderer Props
*/
export interface ErrorFallbackRendererProps {
/** 发生错误的 Artifact */
artifact: Artifact;
/** 错误对象 */
error?: Error | null;
/** 重试回调 */
onRetry?: () => void;
/** 显示源码回调 */
onShowSource?: () => void;
/** 自定义类名 */
className?: string;
}
/**
* 错误详情展开状态
*/
interface ErrorDetailsState {
showStack: boolean;
showContent: boolean;
}
// ============================================================================
// 辅助组件
// ============================================================================
/**
* 复制按钮组件
*/
const CopyButton: React.FC<{
text: string;
label?: string;
className?: string;
}> = memo(({ text, label = "复制", className }) => {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("[ErrorFallbackRenderer] 复制失败:", err);
}
}, [text]);
return (
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-xs rounded transition-colors",
copied
? "bg-green-500/20 text-green-400"
: "bg-white/10 hover:bg-white/15 text-gray-300",
className,
)}
title={copied ? "已复制" : label}
>
{copied ? (
<>
<Check className="w-3 h-3" />
<span>已复制</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
<span>{label}</span>
</>
)}
</button>
);
});
CopyButton.displayName = "CopyButton";
/**
* 可折叠区域组件
*/
const CollapsibleSection: React.FC<{
title: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
className?: string;
}> = memo(({ title, isOpen, onToggle, children, className }) => (
<div className={cn("border border-white/10 rounded", className)}>
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-400 hover:bg-white/5 transition-colors"
>
<span>{title}</span>
{isOpen ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isOpen && <div className="border-t border-white/10">{children}</div>}
</div>
));
CollapsibleSection.displayName = "CollapsibleSection";
// ============================================================================
// 主组件
// ============================================================================
/**
* 格式化错误信息用于复制
*/
function formatErrorForCopy(artifact: Artifact, error?: Error | null): string {
const lines: string[] = [
"=== Artifact 渲染错误报告 ===",
"",
`时间: ${new Date().toISOString()}`,
`Artifact ID: ${artifact.id}`,
`Artifact 类型: ${artifact.type}`,
`Artifact 标题: ${artifact.title}`,
`Artifact 状态: ${artifact.status}`,
"",
];
if (error) {
lines.push("--- 错误信息 ---");
lines.push(`错误类型: ${error.name}`);
lines.push(`错误消息: ${error.message}`);
if (error.stack) {
lines.push("");
lines.push("--- 错误堆栈 ---");
lines.push(error.stack);
}
}
lines.push("");
lines.push("--- Artifact 内容 ---");
lines.push(artifact.content);
return lines.join("\n");
}
/**
* 错误回退渲染器
*
* 功能特性:
* - 显示友好的错误信息 (Requirement 14.2)
* - 提供重试按钮 (Requirement 14.3)
* - 错误时可以查看原始源码 (Requirement 14.5)
* - 支持复制错误信息用于调试
* - 可折叠的错误详情和源码区域
*
* @param artifact - 发生错误的 Artifact
* @param error - 错误对象
* @param onRetry - 重试回调
* @param onShowSource - 显示源码回调
* @param className - 自定义类名
*
* @requirements 14.1, 14.2, 14.3, 14.5
*/
export const ErrorFallbackRenderer: React.FC<ErrorFallbackRendererProps> = memo(
({ artifact, error, onRetry, onShowSource, className }) => {
// 展开状态管理
const [details, setDetails] = useState<ErrorDetailsState>({
showStack: false,
showContent: true, // 默认展开源码
});
// 切换错误堆栈显示
const toggleStack = useCallback(() => {
setDetails((prev) => ({ ...prev, showStack: !prev.showStack }));
}, []);
// 切换内容显示
const toggleContent = useCallback(() => {
setDetails((prev) => ({ ...prev, showContent: !prev.showContent }));
}, []);
// 格式化的错误报告
const errorReport = formatErrorForCopy(artifact, error);
return (
<div className={cn("flex flex-col h-full bg-[#1e2227]", className)}>
{/* 错误提示区域 */}
<div className="p-4 bg-red-500/10 border-b border-red-500/20">
{/* 错误标题 */}
<div className="flex items-center gap-2 text-red-400 font-medium mb-2">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<span>渲染失败</span>
</div>
{/* 错误消息 */}
{error && (
<div className="mb-3">
<div className="text-sm text-gray-300 mb-1">
{error.message || "未知错误"}
</div>
{error.name && error.name !== "Error" && (
<div className="text-xs text-gray-500">
错误类型: {error.name}
</div>
)}
</div>
)}
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2">
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white/10 hover:bg-white/15 rounded transition-colors"
>
<RefreshCw className="w-4 h-4" />
重试
</button>
)}
{onShowSource && (
<button
onClick={onShowSource}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white/10 hover:bg-white/15 rounded transition-colors"
>
<Code className="w-4 h-4" />
查看源码
</button>
)}
<CopyButton
text={errorReport}
label="复制错误报告"
className="px-3 py-1.5 text-sm"
/>
</div>
</div>
{/* 详情区域 */}
<div className="flex-1 overflow-auto p-4 space-y-3">
{/* Artifact 信息 */}
<div className="text-xs text-gray-500 space-y-1">
<div>
<span className="text-gray-600">类型:</span>{" "}
<span className="text-gray-400">{artifact.type}</span>
</div>
<div>
<span className="text-gray-600">标题:</span>{" "}
<span className="text-gray-400">
{artifact.title || "(无标题)"}
</span>
</div>
<div>
<span className="text-gray-600">状态:</span>{" "}
<span className="text-gray-400">{artifact.status}</span>
</div>
</div>
{/* 错误堆栈(可折叠) */}
{error?.stack && (
<CollapsibleSection
title="错误堆栈"
isOpen={details.showStack}
onToggle={toggleStack}
>
<div className="relative">
<pre className="p-3 text-xs text-red-300/80 overflow-auto whitespace-pre-wrap break-all max-h-[200px] bg-black/20">
{error.stack}
</pre>
<div className="absolute top-2 right-2">
<CopyButton text={error.stack} label="复制" />
</div>
</div>
</CollapsibleSection>
)}
{/* 源码内容(可折叠) */}
<CollapsibleSection
title="原始内容"
isOpen={details.showContent}
onToggle={toggleContent}
>
<div className="relative">
<pre className="p-3 text-xs text-gray-300 overflow-auto whitespace-pre-wrap break-all max-h-[400px] bg-black/20">
{artifact.content || "(空内容)"}
</pre>
{artifact.content && (
<div className="absolute top-2 right-2">
<CopyButton text={artifact.content} label="复制" />
</div>
)}
</div>
</CollapsibleSection>
</div>
</div>
);
},
);
ErrorFallbackRenderer.displayName = "ErrorFallbackRenderer";
export default ErrorFallbackRenderer;
+335
View File
@@ -0,0 +1,335 @@
# Artifact 组件
Artifact 系统的 UI 组件,用于渲染和管理各种类型的结构化内容。
## 目录结构
```
artifact/
├── renderers/ # 各类型渲染器
│ ├── CodeRenderer.tsx # 代码渲染器
│ ├── HtmlRenderer.tsx # HTML 渲染器
│ ├── SvgRenderer.tsx # SVG 渲染器
│ ├── MermaidRenderer.tsx # Mermaid 渲染器
│ ├── ReactRenderer.tsx # React 渲染器
│ └── index.ts # 渲染器导出
├── ArtifactList.tsx # 列表组件
├── ArtifactPanel.tsx # 侧边面板组件
├── ArtifactRenderer.tsx # 统一渲染入口
├── ArtifactToolbar.tsx # 工具栏组件
├── CanvasAdapter.tsx # Canvas 系统适配器
├── ErrorFallbackRenderer.tsx # 错误回退渲染器
├── README.md # 本文件
└── index.ts # 模块导出(待创建)
```
## 渲染器组件
### CodeRenderer
代码渲染器,支持语法高亮、行号显示、复制功能和流式内容更新。
**功能特性:**
- 使用 react-syntax-highlighter 实现语法高亮
- 显示行号(超过 1 行时)
- 提供复制到剪贴板功能
- 支持从 artifact 元数据检测语言
- 支持流式内容更新,无闪烁
**使用示例:**
```tsx
import { CodeRenderer } from '@/components/artifact/renderers/CodeRenderer';
<CodeRenderer
artifact={{
id: '1',
type: 'code',
title: 'example.ts',
content: 'const hello = "world";',
status: 'complete',
meta: { language: 'typescript' },
position: { start: 0, end: 0 },
createdAt: Date.now(),
updatedAt: Date.now(),
}}
isStreaming={false}
/>
```
## 相关文档
- [Artifact 类型定义](../../lib/artifact/types.ts)
- [Artifact 解析器](../../lib/artifact/parser.ts)
- [Artifact 状态管理](../../lib/artifact/store.ts)
## ArtifactRenderer
Artifact 统一渲染入口组件,根据类型分发到对应的渲染器。
**功能特性:**
- 根据 Artifact 类型分发到对应的渲染器
- Canvas 类型委托给 Canvas 系统处理
- 错误边界捕获渲染错误,显示友好的错误信息
- 支持流式状态指示器
- 使用 React.Suspense 支持懒加载渲染器
**使用示例:**
```tsx
import { ArtifactRenderer } from '@/components/artifact/ArtifactRenderer';
<ArtifactRenderer
artifact={artifact}
isStreaming={false}
onContentChange={(content) => updateArtifact(artifact.id, { content })}
/>
```
**Props:**
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| artifact | Artifact | 是 | 要渲染的 Artifact 对象 |
| isStreaming | boolean | 否 | 是否处于流式生成状态 |
| onContentChange | (content: string) => void | 否 | 内容变更回调 |
| className | string | 否 | 自定义类名 |
**类型分发逻辑:**
1. 检查 ArtifactRegistry 是否有对应类型的渲染器
2. 如果是 Canvas 类型(`canvas:*`),委托给 CanvasAdapter
3. 如果有注册的渲染器,使用 Suspense 懒加载渲染
4. 如果没有注册的渲染器,显示 FallbackRenderer
**错误处理:**
- 使用 ArtifactErrorBoundary 捕获渲染错误
- 错误时显示友好的错误信息和重试按钮
- 支持查看源码回退
---
## CanvasAdapter
Canvas 适配器组件,将 Canvas 类型的 Artifact 适配到现有 Canvas 系统。
**功能特性:**
- 检测 Canvas 类型(canvas:document, canvas:poster, canvas:music, canvas:script, canvas:novel)
- 将 Artifact 内容作为初始状态传递给 Canvas
- 同步 Canvas 状态变更回 Artifact
- 支持在完整 Canvas 编辑器模式中打开
- 保留 Canvas 特定元数据(platform, version 等)
**支持的 Canvas 类型:**
| Artifact 类型 | Canvas 类型 | 说明 |
|--------------|-------------|------|
| canvas:document | document | 文档画布 |
| canvas:poster | poster | 海报画布 |
| canvas:music | music | 音乐画布 |
| canvas:script | script | 剧本画布 |
| canvas:novel | novel | 小说画布 |
**使用示例:**
```tsx
import { CanvasAdapter } from '@/components/artifact/CanvasAdapter';
<CanvasAdapter
artifact={{
id: '1',
type: 'canvas:document',
title: '我的文档',
content: '# Hello World',
status: 'complete',
meta: { platform: 'markdown' },
position: { start: 0, end: 0 },
createdAt: Date.now(),
updatedAt: Date.now(),
}}
isStreaming={false}
onContentChange={(content) => console.log('内容变更:', content)}
/>
```
**Props:**
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| artifact | Artifact | 是 | 要渲染的 Artifact 对象 |
| isStreaming | boolean | 否 | 是否处于流式生成状态 |
| onContentChange | (content: string) => void | 否 | 内容变更回调 |
| className | string | 否 | 自定义类名 |
**工具函数:**
- `getCanvasTypeFromArtifact(type)`: 从 Artifact 类型获取 Canvas 类型
- `isCanvasArtifact(type)`: 检测是否为 Canvas 类型的 Artifact
- `createCanvasStateFromArtifact(artifact)`: 根据 Artifact 创建初始 Canvas 状态
- `extractContentFromCanvasState(state)`: 从 Canvas 状态提取内容
- `extractCanvasMetadata(state)`: 提取 Canvas 元数据
---
## ErrorFallbackRenderer
错误回退渲染器,当 Artifact 渲染失败时显示友好的错误信息。
**功能特性:**
- 显示友好的错误信息(错误类型、错误消息)
- 提供重试按钮,支持重新渲染
- 错误时可以查看原始源码
- 支持复制完整错误报告用于调试
- 可折叠的错误堆栈和源码区域
- 显示 Artifact 元信息(类型、标题、状态)
**使用示例:**
```tsx
import { ErrorFallbackRenderer } from '@/components/artifact/ErrorFallbackRenderer';
<ErrorFallbackRenderer
artifact={artifact}
error={new Error('渲染失败')}
onRetry={() => setRetryKey(k => k + 1)}
onShowSource={() => setShowSource(true)}
/>
```
**Props:**
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| artifact | Artifact | 是 | 发生错误的 Artifact 对象 |
| error | Error \| null | 否 | 错误对象 |
| onRetry | () => void | 否 | 重试回调 |
| onShowSource | () => void | 否 | 显示源码回调 |
| className | string | 否 | 自定义类名 |
**错误报告格式:**
复制错误报告时,会生成包含以下信息的文本:
- 时间戳
- Artifact ID、类型、标题、状态
- 错误类型和消息
- 错误堆栈(如有)
- Artifact 原始内容
---
## ArtifactList
Artifact 列表组件,显示当前消息中的所有 artifacts,支持选择交互。
**功能特性:**
- 显示 artifact 列表,包含类型图标和标题
- 支持选择交互,高亮选中项
- 显示流式状态指示器(streaming 状态时显示加载动画)
- 紧凑的垂直布局,适合侧边面板
**使用示例:**
```tsx
import { ArtifactList } from '@/components/artifact/ArtifactList';
<ArtifactList
artifacts={artifacts}
selectedId={selectedArtifact?.id}
onSelect={(id) => selectArtifact(id)}
/>
```
**Props:**
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| artifacts | Artifact[] | 是 | Artifact 列表 |
| selectedId | string \| null | 否 | 当前选中的 Artifact ID |
| onSelect | (id: string) => void | 否 | 选择回调 |
| className | string | 否 | 自定义类名 |
## ArtifactToolbar
Artifact 工具栏组件,提供快捷操作功能。
**功能特性:**
- 复制内容到剪贴板
- 下载文件(根据类型自动选择扩展名)
- 源码/预览视图切换
- 在新窗口中打开
- 关闭面板
**使用示例:**
```tsx
import { ArtifactToolbar } from '@/components/artifact/ArtifactToolbar';
<ArtifactToolbar
artifact={artifact}
showSource={false}
onToggleSource={() => setShowSource(!showSource)}
onClose={() => closePanel()}
/>
```
**Props:**
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| artifact | Artifact | 是 | 要操作的 Artifact 对象 |
| showSource | boolean | 否 | 当前是否显示源码视图 |
| onToggleSource | () => void | 否 | 源码切换回调 |
| onClose | () => void | 否 | 关闭回调 |
---
## ArtifactPanel
Artifact 侧边面板组件,可调整大小的侧边面板,集成所有 Artifact 相关组件。
**功能特性:**
- 可调整大小的侧边面板(拖拽左边缘调整宽度)
- 集成 ArtifactList、ArtifactToolbar、ArtifactRenderer
- 键盘导航:上下键选择 artifact
- 快捷键:Escape 关闭面板
- 支持展开/折叠
- 宽度持久化(使用 Jotai atomWithStorage)
**使用示例:**
```tsx
import { ArtifactPanel } from '@/components/artifact/ArtifactPanel';
// 基本用法
<ArtifactPanel onClose={() => setPanelOpen(false)} />
// 配合状态管理
import { useAtom } from 'jotai';
import { artifactPanelStateAtom } from '@/lib/artifact/store';
function App() {
const [panelState, setPanelState] = useAtom(artifactPanelStateAtom);
return (
<div className="flex h-screen">
<main className="flex-1">
{/* 主内容区域 */}
</main>
{panelState.isOpen && (
<ArtifactPanel
onClose={() => setPanelState(s => ({ ...s, isOpen: false }))}
/>
)}
</div>
);
}
```
**Props:**
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| onClose | () => void | 否 | 关闭面板回调 |
| className | string | 否 | 自定义类名 |
**键盘快捷键:**
| 快捷键 | 功能 |
|--------|------|
| ↑ / ↓ | 在 artifact 列表中上下选择 |
| Escape | 关闭面板 |
**面板尺寸:**
- 最小宽度:320px
- 最大宽度:800px
- 默认宽度:480px
- 折叠宽度:48px
**状态持久化:**
面板宽度通过 `artifactPanelStateAtom` 持久化到 sessionStorage,包含:
- `isOpen`: 面板是否打开
- `width`: 面板宽度(像素)
@@ -0,0 +1,247 @@
/**
* @file Canvas 适配器工具函数
* @description 将 Canvas 类型的 Artifact 适配到现有 Canvas 系统的工具函数
* @module components/artifact/canvasAdapterUtils
* @requirements 12.1, 12.2, 12.3, 12.5
*/
import type { Artifact, ArtifactType } from "@/lib/artifact/types";
// Canvas 系统导入
import type {
CanvasStateUnion,
CanvasType,
} from "@/components/content-creator/canvas/canvasUtils";
import { createInitialDocumentState } from "@/components/content-creator/canvas/document";
import { createInitialPosterState } from "@/components/content-creator/canvas/poster";
import { createInitialMusicState } from "@/components/content-creator/canvas/music";
import { createInitialScriptState } from "@/components/content-creator/canvas/script";
import { createInitialNovelState } from "@/components/content-creator/canvas/novel";
import type { DocumentCanvasState } from "@/components/content-creator/canvas/document/types";
import type { PosterCanvasState } from "@/components/content-creator/canvas/poster/types";
import type { MusicCanvasState } from "@/components/content-creator/canvas/music/types";
import type { ScriptCanvasState } from "@/components/content-creator/canvas/script/types";
import type { NovelCanvasState } from "@/components/content-creator/canvas/novel/types";
// ============================================================================
// 类型定义
// ============================================================================
/**
* Canvas 元数据接口
* 保留 Canvas 特定的元数据
* @requirements 12.5
*/
export interface CanvasMetadata {
/** Canvas 平台类型 */
platform?: string;
/** Canvas 版本 */
version?: string;
/** 其他自定义数据 */
[key: string]: unknown;
}
// ============================================================================
// 常量定义
// ============================================================================
/**
* Artifact Canvas 类型到 Canvas 系统类型的映射
*/
export const ARTIFACT_TO_CANVAS_TYPE: Record<string, CanvasType> = {
"canvas:document": "document",
"canvas:poster": "poster",
"canvas:music": "music",
"canvas:script": "script",
"canvas:novel": "novel",
};
/**
* Canvas 类型显示名称
*/
export const CANVAS_TYPE_LABELS: Record<CanvasType, string> = {
document: "文档",
poster: "海报",
music: "音乐",
script: "剧本",
novel: "小说",
};
/**
* Canvas 类型图标
*/
export const CANVAS_TYPE_ICONS: Record<CanvasType, string> = {
document: "📄",
poster: "🎨",
music: "🎵",
script: "🎬",
novel: "📚",
};
// ============================================================================
// 工具函数
// ============================================================================
/**
* 从 Artifact 类型获取 Canvas 类型
* @param artifactType - Artifact 类型
* @returns Canvas 类型,如果不是 Canvas 类型则返回 null
* @requirements 12.1
*/
export function getCanvasTypeFromArtifact(
artifactType: ArtifactType,
): CanvasType | null {
return ARTIFACT_TO_CANVAS_TYPE[artifactType] || null;
}
/**
* 检测是否为 Canvas 类型的 Artifact
* @param artifactType - Artifact 类型
* @returns 是否为 Canvas 类型
* @requirements 12.1
*/
export function isCanvasArtifact(artifactType: ArtifactType): boolean {
return artifactType.startsWith("canvas:");
}
/**
* 验证文档平台类型
*/
function isValidDocumentPlatform(
platform: string,
): platform is "wechat" | "xiaohongshu" | "zhihu" | "markdown" {
return ["wechat", "xiaohongshu", "zhihu", "markdown"].includes(platform);
}
/**
* 根据 Artifact 创建初始 Canvas 状态
* @param artifact - Artifact 对象
* @returns Canvas 状态,如果类型不支持则返回 null
* @requirements 12.2
*/
export function createCanvasStateFromArtifact(
artifact: Artifact,
): CanvasStateUnion | null {
const canvasType = getCanvasTypeFromArtifact(artifact.type);
if (!canvasType) return null;
const content = artifact.content;
const meta = artifact.meta as CanvasMetadata;
switch (canvasType) {
case "document": {
const state = createInitialDocumentState(content);
// 应用元数据中的平台设置
if (meta.platform && isValidDocumentPlatform(meta.platform)) {
return { ...state, platform: meta.platform } as DocumentCanvasState;
}
return state;
}
case "poster":
return createInitialPosterState();
case "music":
return createInitialMusicState();
case "script":
return createInitialScriptState(content);
case "novel":
return createInitialNovelState(content);
default:
return null;
}
}
/**
* 剧本状态转文本(简化版)
*/
function scriptStateToText(state: ScriptCanvasState): string {
let text = "";
if (state.title) text += `# ${state.title}\n\n`;
if (state.synopsis) text += `${state.synopsis}\n\n`;
for (const scene of state.scenes) {
text += `## 第${scene.number}场:${scene.location}(${scene.time})\n\n`;
if (scene.description) text += `*${scene.description}*\n\n`;
for (const dialogue of scene.dialogues) {
if (dialogue.direction) text += `(${dialogue.direction})\n`;
text += `${dialogue.characterName}:${dialogue.content}\n`;
}
text += "\n";
}
return text;
}
/**
* 小说状态转文本(简化版)
*/
function novelStateToText(state: NovelCanvasState): string {
let text = "";
if (state.title) text += `# ${state.title}\n\n`;
if (state.synopsis) text += `> ${state.synopsis}\n\n`;
for (const chapter of state.chapters) {
text += `## ${chapter.title}\n\n${chapter.content}\n\n`;
}
return text;
}
/**
* 从 Canvas 状态提取内容
* @param state - Canvas 状态
* @returns 内容字符串
* @requirements 12.3
*/
export function extractContentFromCanvasState(state: CanvasStateUnion): string {
switch (state.type) {
case "document":
return (state as DocumentCanvasState).content;
case "poster":
// 海报状态序列化为 JSON
return JSON.stringify(state, null, 2);
case "music":
// 音乐状态序列化为 JSON
return JSON.stringify(state, null, 2);
case "script": {
// 剧本状态转换为文本
const scriptState = state as ScriptCanvasState;
return scriptStateToText(scriptState);
}
case "novel": {
// 小说状态转换为文本
const novelState = state as NovelCanvasState;
return novelStateToText(novelState);
}
default:
return "";
}
}
/**
* 提取 Canvas 元数据
* @param state - Canvas 状态
* @returns Canvas 元数据
* @requirements 12.5
*/
export function extractCanvasMetadata(state: CanvasStateUnion): CanvasMetadata {
const metadata: CanvasMetadata = {
version: "1.0",
};
switch (state.type) {
case "document":
metadata.platform = (state as DocumentCanvasState).platform;
break;
case "poster":
metadata.pageCount = (state as PosterCanvasState).pages.length;
break;
case "music":
metadata.songType = (state as MusicCanvasState).spec.songType;
metadata.viewMode = (state as MusicCanvasState).viewMode;
break;
case "script":
metadata.sceneCount = (state as ScriptCanvasState).scenes.length;
break;
case "novel":
metadata.chapterCount = (state as NovelCanvasState).chapters.length;
break;
}
return metadata;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* @file Artifact 组件模块导出入口
* @description 导出所有公共组件和类型,提供统一的模块访问入口
* @module components/artifact
* @requirements 1.1
*/
// ============================================================================
// 核心组件导出
// ============================================================================
/**
* Artifact 统一渲染入口组件
* 根据 Artifact 类型分发到对应的渲染器
*/
export { ArtifactRenderer, ArtifactErrorBoundary } from "./ArtifactRenderer";
export type { ArtifactRendererComponentProps } from "./ArtifactRenderer";
/**
* Artifact 侧边面板组件
* 可调整大小的侧边面板,集成列表、工具栏和渲染器
*/
export { ArtifactPanel } from "./ArtifactPanel";
export type { ArtifactPanelProps } from "./ArtifactPanel";
/**
* Artifact 工具栏组件
* 提供复制、下载、源码切换等快捷操作
*/
export { ArtifactToolbar } from "./ArtifactToolbar";
export type { ArtifactToolbarProps } from "./ArtifactToolbar";
/**
* Artifact 列表组件
* 显示当前消息中的所有 artifacts
*/
export { ArtifactList } from "./ArtifactList";
export type { ArtifactListProps } from "./ArtifactList";
/**
* Canvas 适配器组件
* 将 Canvas 类型的 Artifact 适配到现有 Canvas 系统
*/
export { CanvasAdapter } from "./CanvasAdapter";
export type { CanvasAdapterProps } from "./CanvasAdapter";
/**
* Canvas 适配器工具函数
* 从单独的工具文件导出,避免 react-refresh 警告
*/
export {
getCanvasTypeFromArtifact,
isCanvasArtifact,
createCanvasStateFromArtifact,
extractContentFromCanvasState,
extractCanvasMetadata,
ARTIFACT_TO_CANVAS_TYPE,
CANVAS_TYPE_LABELS,
CANVAS_TYPE_ICONS,
} from "./canvasAdapterUtils";
export type { CanvasMetadata } from "./canvasAdapterUtils";
/**
* 错误回退渲染器
* 当 Artifact 渲染失败时显示友好的错误信息
*/
export { ErrorFallbackRenderer } from "./ErrorFallbackRenderer";
export type { ErrorFallbackRendererProps } from "./ErrorFallbackRenderer";
// ============================================================================
// 渲染器导出
// ============================================================================
/**
* 轻量渲染器组件
* 包括 CodeRenderer, HtmlRenderer, SvgRenderer, MermaidRenderer, ReactRenderer
*/
export {
CodeRenderer,
HtmlRenderer,
SvgRenderer,
MermaidRenderer,
ReactRenderer,
registerLightweightRenderers,
getLightweightRendererEntries,
areLightweightRenderersRegistered,
} from "./renderers";
// ============================================================================
// 类型重导出(便于使用)
// ============================================================================
/**
* 从 lib/artifact 重导出核心类型
* 方便组件使用者直接从 components/artifact 导入类型
*/
export type {
Artifact,
ArtifactType,
ArtifactStatus,
ArtifactMeta,
ArtifactRendererProps,
RendererEntry,
} from "@/lib/artifact/types";
export {
LIGHTWEIGHT_ARTIFACT_TYPES,
CANVAS_ARTIFACT_TYPES,
ALL_ARTIFACT_TYPES,
DEFAULT_FILE_EXTENSIONS,
isCanvasType,
isLightweightType,
} from "@/lib/artifact/types";
@@ -0,0 +1,260 @@
/**
* @file 代码渲染器组件
* @description Artifact 系统的代码渲染器,支持语法高亮、行号显示、复制功能和流式内容更新
* @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 { cn } from "@/lib/utils";
import type { ArtifactRendererProps } from "@/lib/artifact/types";
/**
* 语言名称映射表
* 将常见的语言别名映射到 Prism 支持的语言名称
*/
const LANGUAGE_ALIASES: Record<string, string> = {
js: "javascript",
ts: "typescript",
tsx: "tsx",
jsx: "jsx",
py: "python",
rb: "ruby",
rs: "rust",
go: "go",
sh: "bash",
shell: "bash",
zsh: "bash",
yml: "yaml",
md: "markdown",
dockerfile: "docker",
plaintext: "text",
txt: "text",
};
/**
* 规范化语言名称
* @param language - 原始语言名称
* @returns 规范化后的语言名称
*/
function normalizeLanguage(language: string | undefined): string {
if (!language) return "text";
const lower = language.toLowerCase().trim();
return LANGUAGE_ALIASES[lower] || lower;
}
/**
* 流式指示器组件
* 显示在代码块底部,表示内容正在流式生成中
*/
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";
/**
* 复制按钮组件
*/
interface CopyButtonProps {
copied: boolean;
onClick: () => void;
}
const CopyButton: React.FC<CopyButtonProps> = memo(({ copied, onClick }) => (
<button
onClick={onClick}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded text-xs transition-all",
"hover:bg-white/10",
copied ? "text-green-400" : "text-gray-400 hover:text-white",
)}
title={copied ? "已复制" : "复制代码"}
>
{copied ? (
<>
<Check className="w-3.5 h-3.5" />
<span>已复制</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>复制</span>
</>
)}
</button>
));
CopyButton.displayName = "CopyButton";
/**
* 错误显示组件
* Requirement 14.4
*/
interface ErrorDisplayProps {
message: string;
content: string;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = memo(
({ message, content }) => (
<div className="flex flex-col h-full bg-[#282c34]">
<div className="flex items-start gap-3 p-4 bg-red-900/30 border-b border-red-500/30">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-medium text-red-300 mb-1">
代码渲染失败
</h3>
<p className="text-xs text-red-400">{message}</p>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<h4 className="text-xs font-medium text-gray-500 mb-2">原始内容:</h4>
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap break-all">
{content}
</pre>
</div>
</div>
),
);
ErrorDisplay.displayName = "ErrorDisplay";
/**
* 代码渲染器组件
*
* 功能特性:
* - 使用 react-syntax-highlighter 实现语法高亮 (Requirement 4.1)
* - 显示行号 (Requirement 4.2)
* - 提供复制到剪贴板功能 (Requirement 4.3)
* - 支持从 artifact 元数据检测语言 (Requirement 4.4)
* - 支持流式内容更新,无闪烁 (Requirement 4.6)
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
*/
export const CodeRenderer: React.FC<ArtifactRendererProps> = memo(
({ artifact, isStreaming = false }) => {
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
// 从 artifact 元数据获取语言,并规范化
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,
);
return "text";
}
}, [artifact.meta.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,
);
}
}, [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]);
// 验证内容是否可渲染
useMemo(() => {
try {
if (artifact.content === null || artifact.content === undefined) {
throw new Error("代码内容为空");
}
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="flex-1 overflow-auto">
<SyntaxHighlighter
language={language}
style={oneDark}
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 />}
</div>
);
},
);
CodeRenderer.displayName = "CodeRenderer";
export default CodeRenderer;
@@ -0,0 +1,426 @@
/**
* @file HTML 渲染器组件
* @description Artifact 系统的 HTML 渲染器,支持沙箱化 iframe 预览、源码切换、响应式尺寸和刷新功能
* @module components/artifact/renderers/HtmlRenderer
* @requirements 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 14.4
*/
import React, {
useState,
useRef,
useCallback,
memo,
useMemo,
useEffect,
} from "react";
import {
Eye,
Code2,
RefreshCw,
Smartphone,
Tablet,
Monitor,
Loader2,
AlertCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ArtifactRendererProps } from "@/lib/artifact/types";
import { CodeRenderer } from "./CodeRenderer";
/**
* 预览尺寸类型
*/
type PreviewSize = "mobile" | "tablet" | "desktop";
/**
* 预览尺寸配置
* 定义各设备尺寸的宽度
*/
const PREVIEW_WIDTHS: Record<PreviewSize, number | string> = {
mobile: 375,
tablet: 768,
desktop: "100%",
};
/**
* 尺寸选项配置
*/
const SIZE_OPTIONS: Array<{
value: PreviewSize;
label: string;
icon: React.ComponentType<{ className?: string }>;
}> = [
{ value: "mobile", label: "手机", icon: Smartphone },
{ value: "tablet", label: "平板", icon: Tablet },
{ value: "desktop", label: "桌面", icon: Monitor },
];
/**
* 视图模式类型
*/
type ViewMode = "preview" | "source";
/**
* 流式指示器组件
*/
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";
/**
* 错误显示组件
* Requirement 14.4
*/
interface ErrorDisplayProps {
message: string;
content: string;
onRetry?: () => void;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = memo(
({ message, content, onRetry }) => (
<div className="flex flex-col h-full">
<div className="flex items-start gap-3 p-4 bg-red-50 border-b border-red-100">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-sm font-medium text-red-800 mb-1">
HTML 渲染失败
</h3>
<p className="text-xs text-red-600">{message}</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium text-red-700 hover:bg-red-100 transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>重试</span>
</button>
)}
</div>
<div className="flex-1 overflow-auto p-4 bg-gray-50">
<h4 className="text-xs font-medium text-gray-500 mb-2">源码内容:</h4>
<pre className="text-xs text-gray-700 font-mono whitespace-pre-wrap break-all bg-white p-3 rounded border border-gray-200">
{content}
</pre>
</div>
</div>
),
);
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-md bg-gray-100 p-1">
<button
type="button"
onClick={() => onChange("preview")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "preview"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="预览模式"
>
<Eye className="w-3.5 h-3.5" />
<span>预览</span>
</button>
<button
type="button"
onClick={() => onChange("source")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "source"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="源码模式"
>
<Code2 className="w-3.5 h-3.5" />
<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-md bg-gray-100 p-1">
{SIZE_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"inline-flex items-center justify-center w-8 h-7 rounded transition-all",
value === option.value
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700",
)}
title={option.label}
>
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
),
);
SizeSelector.displayName = "SizeSelector";
/**
* 刷新按钮组件
*/
interface RefreshButtonProps {
onClick: () => void;
isRefreshing?: boolean;
}
const RefreshButton: React.FC<RefreshButtonProps> = memo(
({ onClick, isRefreshing }) => (
<button
type="button"
onClick={onClick}
disabled={isRefreshing}
className={cn(
"inline-flex items-center justify-center w-8 h-8 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
title="刷新预览"
>
<RefreshCw className={cn("w-4 h-4", isRefreshing && "animate-spin")} />
</button>
),
);
RefreshButton.displayName = "RefreshButton";
/**
* HTML 渲染器组件
*
* 功能特性:
* - 在沙箱化 iframe 中渲染 HTML 内容 (Requirement 5.1)
* - 使用 sandbox 属性隔离 iframe (Requirement 5.2)
* - 提供预览/源码视图切换 (Requirement 5.3)
* - 支持响应式预览尺寸(手机/平板/桌面)(Requirement 5.4)
* - 脚本仅在沙箱内执行 (Requirement 5.5)
* - 提供刷新功能重新渲染内容 (Requirement 5.6)
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
*/
export const HtmlRenderer: React.FC<ArtifactRendererProps> = memo(
({ artifact, isStreaming = false }) => {
// 视图模式状态
const [viewMode, setViewMode] = useState<ViewMode>("preview");
// 预览尺寸状态
const [previewSize, setPreviewSize] = useState<PreviewSize>("desktop");
// 刷新状态
const [isRefreshing, setIsRefreshing] = useState(false);
// 错误状态
const [error, setError] = useState<string | null>(null);
// iframe 引用
const iframeRef = useRef<HTMLIFrameElement>(null);
/**
* 验证 HTML 内容
* Requirement 14.4
*/
useEffect(() => {
try {
if (!artifact.content || typeof artifact.content !== "string") {
throw new Error("HTML 内容为空或格式无效");
}
setError(null);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "HTML 验证失败";
console.error(
"[HtmlRenderer] Error validating content:",
errorMessage,
err,
);
setError(errorMessage);
}
}, [artifact.content]);
/**
* 刷新预览
* 通过重新设置 srcdoc 来刷新 iframe 内容
*/
const refreshPreview = useCallback(() => {
try {
if (iframeRef.current) {
setIsRefreshing(true);
setError(null);
// 先清空再设置,确保触发重新渲染
iframeRef.current.srcdoc = "";
// 使用 requestAnimationFrame 确保 DOM 更新后再设置新内容
requestAnimationFrame(() => {
try {
if (iframeRef.current) {
iframeRef.current.srcdoc = artifact.content;
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "刷新失败";
console.error(
"[HtmlRenderer] Error refreshing preview:",
errorMessage,
err,
);
setError(errorMessage);
}
// 短暂延迟后取消刷新状态
setTimeout(() => setIsRefreshing(false), 300);
});
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "刷新失败";
console.error(
"[HtmlRenderer] Error in refreshPreview:",
errorMessage,
err,
);
setError(errorMessage);
setIsRefreshing(false);
}
}, [artifact.content]);
/**
* 处理 iframe 加载错误
* Requirement 14.4
*/
const handleIframeError = useCallback(() => {
const errorMessage = "iframe 加载失败";
console.error("[HtmlRenderer] Error loading iframe:", errorMessage);
setError(errorMessage);
}, []);
/**
* 计算 iframe 样式
*/
const iframeStyle = useMemo(() => {
const width = PREVIEW_WIDTHS[previewSize];
return {
width: typeof width === "number" ? `${width}px` : width,
height: "100%",
maxWidth: "100%",
};
}, [previewSize]);
/**
* 创建用于源码视图的 artifact 对象
*/
const sourceArtifact = useMemo(
() => ({
...artifact,
type: "code" as const,
meta: { ...artifact.meta, language: "html" },
}),
[artifact],
);
return (
<div className="h-full flex flex-col bg-white rounded-lg overflow-hidden border border-gray-200">
{/* 工具栏 */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-gray-200 bg-gray-50">
{/* 视图模式切换 */}
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{/* 预览尺寸选择器(仅在预览模式显示) */}
{viewMode === "preview" && (
<>
<div className="w-px h-5 bg-gray-300" />
<SizeSelector value={previewSize} onChange={setPreviewSize} />
</>
)}
{/* 刷新按钮(仅在预览模式显示) */}
{viewMode === "preview" && (
<RefreshButton
onClick={refreshPreview}
isRefreshing={isRefreshing}
/>
)}
{/* 当前尺寸标签 */}
{viewMode === "preview" && previewSize !== "desktop" && (
<span className="text-xs text-gray-500 ml-auto">
{PREVIEW_WIDTHS[previewSize]}px
</span>
)}
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-auto relative bg-gray-100">
{error ? (
/* 错误显示 - Requirement 14.4 */
<ErrorDisplay
message={error}
content={artifact.content}
onRetry={refreshPreview}
/>
) : viewMode === "preview" ? (
<div className="h-full flex items-start justify-center p-4">
{/*
沙箱化 iframe
- sandbox="allow-scripts" 允许脚本执行但限制其他能力
- 脚本无法访问父窗口、无法导航、无法提交表单等
Requirement 5.1, 5.2, 5.5
*/}
<iframe
ref={iframeRef}
srcDoc={artifact.content}
sandbox="allow-scripts"
style={iframeStyle}
className={cn(
"bg-white border-0 shadow-sm transition-all duration-200",
previewSize !== "desktop" &&
"rounded-lg border border-gray-300",
)}
title={artifact.title || "HTML 预览"}
onError={handleIframeError}
/>
</div>
) : (
/* 源码视图 - 复用 CodeRenderer */
<CodeRenderer artifact={sourceArtifact} isStreaming={isStreaming} />
)}
{/* 流式指示器 */}
{isStreaming && <StreamingIndicator />}
</div>
</div>
);
},
);
HtmlRenderer.displayName = "HtmlRenderer";
export default HtmlRenderer;
@@ -0,0 +1,711 @@
/**
* @file Mermaid 渲染器组件
* @description Artifact 系统的 Mermaid 渲染器,支持图表渲染、导出功能(PNG/SVG)、主题切换和预览/源码切换
* @module components/artifact/renderers/MermaidRenderer
* @requirements 7.1, 7.2, 7.3, 7.4, 7.6, 14.4
*/
import React, {
useState,
useRef,
useCallback,
useMemo,
useEffect,
memo,
} from "react";
import mermaid from "mermaid";
import {
Eye,
Code2,
Download,
ZoomIn,
ZoomOut,
Maximize2,
Loader2,
AlertCircle,
Sun,
Moon,
ChevronDown,
FileImage,
FileCode,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ArtifactRendererProps } from "@/lib/artifact/types";
import { CodeRenderer } from "./CodeRenderer";
/**
* 视图模式类型
*/
type ViewMode = "preview" | "source";
/**
* 主题类型
*/
type MermaidTheme = "default" | "dark" | "forest" | "neutral";
/**
* 导出格式类型
*/
type ExportFormat = "svg" | "png";
/**
* 缩放级别配置
*/
const ZOOM_LEVELS = {
min: 0.25,
max: 4,
step: 0.25,
default: 1,
};
/**
* 主题配置
*/
const THEME_OPTIONS: {
value: MermaidTheme;
label: string;
icon: React.ReactNode;
}[] = [
{ value: "default", label: "默认", icon: <Sun className="w-3.5 h-3.5" /> },
{ value: "dark", label: "深色", icon: <Moon className="w-3.5 h-3.5" /> },
{ value: "forest", label: "森林", icon: <Sun className="w-3.5 h-3.5" /> },
{ value: "neutral", label: "中性", icon: <Sun className="w-3.5 h-3.5" /> },
];
/**
* 流式指示器组件
*/
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";
/**
* 流式占位符组件
*/
const StreamingPlaceholder: React.FC = memo(() => (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Loader2 className="w-12 h-12 text-blue-400 mb-4 animate-spin" />
<h3 className="text-lg font-medium text-gray-900 mb-2">正在生成图表...</h3>
<p className="text-sm text-gray-500">请等待内容生成完成后查看预览</p>
</div>
));
StreamingPlaceholder.displayName = "StreamingPlaceholder";
/**
* 视图模式切换按钮组件
*/
interface ViewModeToggleProps {
value: ViewMode;
onChange: (value: ViewMode) => void;
}
const ViewModeToggle: React.FC<ViewModeToggleProps> = memo(
({ value, onChange }) => (
<div className="inline-flex items-center rounded-md bg-gray-100 p-1">
<button
type="button"
onClick={() => onChange("preview")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "preview"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="预览模式"
>
<Eye className="w-3.5 h-3.5" />
<span>预览</span>
</button>
<button
type="button"
onClick={() => onChange("source")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "source"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="源码模式"
>
<Code2 className="w-3.5 h-3.5" />
<span>源码</span>
</button>
</div>
),
);
ViewModeToggle.displayName = "ViewModeToggle";
/**
* 缩放控制组件
*/
interface ZoomControlsProps {
zoom: number;
onZoomChange: (zoom: number) => void;
}
const ZoomControls: React.FC<ZoomControlsProps> = memo(
({ zoom, onZoomChange }) => {
const handleZoomIn = useCallback(() => {
onZoomChange(Math.min(zoom + ZOOM_LEVELS.step, ZOOM_LEVELS.max));
}, [zoom, onZoomChange]);
const handleZoomOut = useCallback(() => {
onZoomChange(Math.max(zoom - ZOOM_LEVELS.step, ZOOM_LEVELS.min));
}, [zoom, onZoomChange]);
const handleFitToView = useCallback(() => {
onZoomChange(ZOOM_LEVELS.default);
}, [onZoomChange]);
const zoomPercentage = Math.round(zoom * 100);
return (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={handleZoomOut}
disabled={zoom <= ZOOM_LEVELS.min}
className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
title="缩小"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="min-w-[3.5rem] text-center text-xs text-gray-600 font-medium">
{zoomPercentage}%
</span>
<button
type="button"
onClick={handleZoomIn}
disabled={zoom >= ZOOM_LEVELS.max}
className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
title="放大"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleFitToView}
className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
)}
title="适应视图"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
);
},
);
ZoomControls.displayName = "ZoomControls";
/**
* 主题选择器组件
* Requirement 7.6
*/
interface ThemeSelectorProps {
value: MermaidTheme;
onChange: (value: MermaidTheme) => void;
}
const ThemeSelector: React.FC<ThemeSelectorProps> = memo(
({ value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, []);
const currentTheme = THEME_OPTIONS.find((t) => t.value === value);
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium transition-all",
"text-gray-600 hover:text-gray-900 hover:bg-gray-100 border border-gray-200",
)}
title="切换主题"
>
{currentTheme?.icon}
<span>{currentTheme?.label}</span>
<ChevronDown
className={cn(
"w-3 h-3 transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-1 py-1 bg-white rounded-md shadow-lg border border-gray-200 z-10 min-w-[100px]">
{THEME_OPTIONS.map((theme) => (
<button
key={theme.value}
type="button"
onClick={() => {
onChange(theme.value);
setIsOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors",
value === theme.value
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-50",
)}
>
{theme.icon}
<span>{theme.label}</span>
</button>
))}
</div>
)}
</div>
);
},
);
ThemeSelector.displayName = "ThemeSelector";
/**
* 导出菜单组件
* Requirement 7.4
*/
interface ExportMenuProps {
onExport: (format: ExportFormat) => void;
disabled?: boolean;
}
const ExportMenu: React.FC<ExportMenuProps> = memo(({ onExport, disabled }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium transition-all",
"text-gray-600 hover:text-gray-900 hover:bg-gray-100 border border-gray-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
title="导出图表"
>
<Download className="w-3.5 h-3.5" />
<span>导出</span>
<ChevronDown
className={cn("w-3 h-3 transition-transform", isOpen && "rotate-180")}
/>
</button>
{isOpen && (
<div className="absolute top-full right-0 mt-1 py-1 bg-white rounded-md shadow-lg border border-gray-200 z-10 min-w-[120px]">
<button
type="button"
onClick={() => {
onExport("svg");
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileCode className="w-3.5 h-3.5" />
<span>导出 SVG</span>
</button>
<button
type="button"
onClick={() => {
onExport("png");
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileImage className="w-3.5 h-3.5" />
<span>导出 PNG</span>
</button>
</div>
)}
</div>
);
});
ExportMenu.displayName = "ExportMenu";
/**
* 错误显示组件
* Requirement 7.5
*/
interface ErrorDisplayProps {
message: string;
source: string;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = memo(
({ message, source }) => (
<div className="flex flex-col h-full">
<div className="flex items-start gap-3 p-4 bg-red-50 border-b border-red-100">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-medium text-red-800 mb-1">
Mermaid 语法错误
</h3>
<p className="text-xs text-red-600">{message}</p>
</div>
</div>
<div className="flex-1 overflow-auto p-4 bg-gray-50">
<h4 className="text-xs font-medium text-gray-500 mb-2">源码内容:</h4>
<pre className="text-xs text-gray-700 font-mono whitespace-pre-wrap break-all bg-white p-3 rounded border border-gray-200">
{source}
</pre>
</div>
</div>
),
);
ErrorDisplay.displayName = "ErrorDisplay";
/**
* 下载 Blob 文件的辅助函数
* @param blob - 要下载的 Blob 对象
* @param filename - 文件名
*/
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* 将 SVG 转换为 Canvas 的辅助函数
* 用于 PNG 导出
* @param svgString - SVG 字符串
* @returns Promise<HTMLCanvasElement>
*/
async function svgToCanvas(svgString: string): Promise<HTMLCanvasElement> {
return new Promise((resolve, reject) => {
const img = new Image();
const svgBlob = new Blob([svgString], {
type: "image/svg+xml;charset=utf-8",
});
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
// 创建 canvas,使用 2x 分辨率以获得更清晰的图像
const scale = 2;
const canvas = document.createElement("canvas");
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error("无法获取 Canvas 2D 上下文"));
return;
}
// 设置白色背景
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 缩放并绘制图像
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(canvas);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("SVG 图像加载失败"));
};
img.src = url;
});
}
/**
* 生成唯一的 Mermaid 渲染 ID
* 避免多个图表之间的 ID 冲突
*/
let mermaidIdCounter = 0;
function generateMermaidId(): string {
return `mermaid-${Date.now()}-${++mermaidIdCounter}`;
}
/**
* Mermaid 渲染器组件
*
* 功能特性:
* - 使用 mermaid 库渲染 Mermaid 语法图表 (Requirement 7.1)
* - 支持 flowchart, sequence, class, state, ER, gantt 等图表类型 (Requirement 7.2)
* - 提供缩放控制 (Requirement 7.3)
* - 提供导出为 PNG/SVG 功能 (Requirement 7.4)
* - 语法错误时显示错误信息和源码 (Requirement 7.5)
* - 支持主题切换(light/dark)(Requirement 7.6)
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
*/
export const MermaidRenderer: React.FC<ArtifactRendererProps> = memo(
({ artifact, isStreaming = false }) => {
// 视图模式状态
const [viewMode, setViewMode] = useState<ViewMode>("preview");
// 渲染后的 SVG 内容
const [svg, setSvg] = useState<string>("");
// 错误信息
const [error, setError] = useState<string | null>(null);
// 缩放级别
const [zoom, setZoom] = useState(ZOOM_LEVELS.default);
// 主题
const [theme, setTheme] = useState<MermaidTheme>("default");
// 渲染状态
const [isRendering, setIsRendering] = useState(false);
// 容器引用
const containerRef = useRef<HTMLDivElement>(null);
/**
* 初始化 Mermaid 配置并渲染图表
* Requirement 7.1, 7.2, 7.6
*/
useEffect(() => {
// 流式生成时不渲染,等待内容完成
if (isStreaming) {
setSvg("");
setError(null);
return;
}
// 内容为空时不渲染
if (!artifact.content.trim()) {
setSvg("");
setError(null);
return;
}
const renderDiagram = async () => {
setIsRendering(true);
setError(null);
try {
// 初始化 Mermaid 配置
mermaid.initialize({
startOnLoad: false,
theme: theme,
securityLevel: "loose",
fontFamily: "ui-sans-serif, system-ui, sans-serif",
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: "basis",
},
sequence: {
useMaxWidth: true,
diagramMarginX: 50,
diagramMarginY: 10,
},
gantt: {
useMaxWidth: true,
},
});
// 生成唯一 ID 避免冲突
const id = generateMermaidId();
// 渲染图表
const { svg: renderedSvg } = await mermaid.render(
id,
artifact.content,
);
setSvg(renderedSvg);
setError(null);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : "图表渲染失败";
console.error(
"[MermaidRenderer] Error rendering diagram:",
errorMessage,
e,
);
console.error(
"[MermaidRenderer] Failed content:",
artifact.content.substring(0, 200),
);
setError(errorMessage);
setSvg("");
} finally {
setIsRendering(false);
}
};
renderDiagram();
}, [artifact.content, artifact.id, isStreaming, theme]);
/**
* 导出图表
* Requirement 7.4
*/
const handleExport = useCallback(
async (format: ExportFormat) => {
if (!svg) return;
const filename = artifact.meta.filename || artifact.title || "diagram";
try {
if (format === "svg") {
// 导出 SVG
const blob = new Blob([svg], { type: "image/svg+xml" });
downloadBlob(blob, `${filename}.svg`);
} else {
// 导出 PNG
const canvas = await svgToCanvas(svg);
canvas.toBlob((blob) => {
if (blob) {
downloadBlob(blob, `${filename}.png`);
}
}, "image/png");
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : "导出失败";
console.error(
"[MermaidRenderer] Error exporting diagram:",
errorMessage,
e,
);
}
},
[svg, artifact.meta.filename, artifact.title],
);
/**
* 创建用于源码视图的 artifact 对象
*/
const sourceArtifact = useMemo(
() => ({
...artifact,
type: "code" as const,
meta: { ...artifact.meta, language: "mermaid" },
}),
[artifact],
);
return (
<div className="h-full flex flex-col bg-white rounded-lg overflow-hidden border border-gray-200">
{/* 工具栏 */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-gray-200 bg-gray-50 flex-wrap">
{/* 视图模式切换 */}
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{/* 预览模式下的控制项 */}
{viewMode === "preview" && (
<>
<div className="w-px h-5 bg-gray-300" />
{/* 缩放控制 - Requirement 7.3 */}
<ZoomControls zoom={zoom} onZoomChange={setZoom} />
<div className="w-px h-5 bg-gray-300" />
{/* 主题选择器 - Requirement 7.6 */}
<ThemeSelector value={theme} onChange={setTheme} />
</>
)}
{/* 导出菜单 - Requirement 7.4 */}
<div className="ml-auto">
<ExportMenu
onExport={handleExport}
disabled={!svg || isRendering}
/>
</div>
</div>
{/* 内容区域 */}
<div
ref={containerRef}
className="flex-1 overflow-auto relative bg-gray-100"
>
{viewMode === "preview" ? (
error ? (
/* 错误显示 - Requirement 7.5 */
<ErrorDisplay message={error} source={artifact.content} />
) : isStreaming ? (
/* 流式占位符 */
<StreamingPlaceholder />
) : isRendering ? (
/* 渲染中状态 */
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
</div>
) : svg ? (
/* 图表预览 - Requirement 7.1, 7.3 */
<div className="h-full flex items-center justify-center p-4">
<div
className="transition-transform duration-200 ease-out"
style={{
transform: `scale(${zoom})`,
transformOrigin: "center",
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
) : (
/* 空内容状态 */
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Code2 className="w-12 h-12 text-gray-300 mb-4" />
<p className="text-sm text-gray-500">暂无图表内容</p>
</div>
)
) : (
/* 源码视图 - 复用 CodeRenderer */
<CodeRenderer artifact={sourceArtifact} isStreaming={isStreaming} />
)}
{/* 流式指示器 */}
{isStreaming && viewMode === "source" && <StreamingIndicator />}
</div>
</div>
);
},
);
MermaidRenderer.displayName = "MermaidRenderer";
export default MermaidRenderer;
@@ -0,0 +1,55 @@
# Artifact 渲染器
各类型 Artifact 的渲染器组件。
## 文件索引
| 文件 | 说明 | 需求 |
|------|------|------|
| `index.ts` | 模块导出入口,注册所有轻量渲染器 | 3.1 |
| `CodeRenderer.tsx` | 代码渲染器,支持语法高亮 | 4.1, 4.2, 4.3, 4.4, 4.6 |
| `HtmlRenderer.tsx` | HTML 渲染器,沙箱化 iframe 预览 | 5.1, 5.2, 5.3, 5.4, 5.5, 5.6 |
| `SvgRenderer.tsx` | SVG 渲染器,支持缩放和下载 | 6.1, 6.2, 6.4, 6.5 |
| `MermaidRenderer.tsx` | Mermaid 图表渲染器,支持导出和主题切换 | 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 |
| `ReactRenderer.tsx` | React 组件渲染器,支持 JSX 编译和沙箱化渲染 | 8.1, 8.2, 8.4, 8.6, 8.7 |
## 渲染器接口
所有渲染器都实现 `ArtifactRendererProps` 接口:
```typescript
interface ArtifactRendererProps {
artifact: Artifact;
isStreaming?: boolean;
onContentChange?: (content: string) => void;
}
```
## 使用方式
### 注册渲染器
在应用初始化时调用 `registerLightweightRenderers()` 注册所有轻量渲染器:
```typescript
import { registerLightweightRenderers } from '@/components/artifact/renderers';
// 在应用入口处调用
registerLightweightRenderers();
```
### 导入单个渲染器
```typescript
import { CodeRenderer, HtmlRenderer } from '@/components/artifact/renderers';
```
### 检查注册状态
```typescript
import { areLightweightRenderersRegistered } from '@/components/artifact/renderers';
if (!areLightweightRenderersRegistered()) {
registerLightweightRenderers();
}
```
@@ -0,0 +1,474 @@
/**
* @file React 渲染器组件
* @description Artifact 系统的 React 渲染器,支持 JSX 编译、沙箱化渲染、错误边界和预览/源码切换
* @module components/artifact/renderers/ReactRenderer
* @requirements 8.1, 8.2, 8.4, 8.6, 8.7, 14.4
*/
import React, {
useState,
useEffect,
useCallback,
useMemo,
memo,
Component,
} from "react";
import * as Babel from "@babel/standalone";
import { Eye, Code2, Loader2, AlertCircle, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ArtifactRendererProps } from "@/lib/artifact/types";
import { CodeRenderer } from "./CodeRenderer";
/**
* 视图模式类型
*/
type ViewMode = "preview" | "source";
/**
* 错误边界 Props
*/
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
/**
* 错误边界 State
*/
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* 错误边界组件
* 捕获子组件渲染过程中的错误,防止整个应用崩溃
* Requirement 8.4
*/
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error(
"[ReactRenderer] Component render error:",
error.message,
error,
);
console.error("[ReactRenderer] Error info:", errorInfo.componentStack);
this.props.onError?.(error, errorInfo);
}
render(): React.ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
/**
* 流式指示器组件
*/
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";
/**
* 流式占位符组件
*/
const StreamingPlaceholder: React.FC = memo(() => (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Loader2 className="w-12 h-12 text-blue-400 mb-4 animate-spin" />
<h3 className="text-lg font-medium text-gray-900 mb-2">正在生成组件...</h3>
<p className="text-sm text-gray-500">请等待内容生成完成后查看预览</p>
</div>
));
StreamingPlaceholder.displayName = "StreamingPlaceholder";
/**
* 视图模式切换按钮组件
*/
interface ViewModeToggleProps {
value: ViewMode;
onChange: (value: ViewMode) => void;
}
const ViewModeToggle: React.FC<ViewModeToggleProps> = memo(
({ value, onChange }) => (
<div className="inline-flex items-center rounded-md bg-gray-100 p-1">
<button
type="button"
onClick={() => onChange("preview")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "preview"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="预览模式"
>
<Eye className="w-3.5 h-3.5" />
<span>预览</span>
</button>
<button
type="button"
onClick={() => onChange("source")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "source"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="源码模式"
>
<Code2 className="w-3.5 h-3.5" />
<span>源码</span>
</button>
</div>
),
);
ViewModeToggle.displayName = "ViewModeToggle";
/**
* 错误显示组件
* Requirement 8.5
*/
interface ErrorDisplayProps {
message: string;
source?: string;
onRetry?: () => void;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = memo(
({ message, source, onRetry }) => (
<div className="flex flex-col h-full">
<div className="flex items-start gap-3 p-4 bg-red-50 border-b border-red-100">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-sm font-medium text-red-800 mb-1">
编译/渲染错误
</h3>
<p className="text-xs text-red-600 whitespace-pre-wrap">{message}</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium text-red-700 hover:bg-red-100 transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>重试</span>
</button>
)}
</div>
{source && (
<div className="flex-1 overflow-auto p-4 bg-gray-50">
<h4 className="text-xs font-medium text-gray-500 mb-2">源码内容:</h4>
<pre className="text-xs text-gray-700 font-mono whitespace-pre-wrap break-all bg-white p-3 rounded border border-gray-200">
{source}
</pre>
</div>
)}
</div>
),
);
ErrorDisplay.displayName = "ErrorDisplay";
/**
* 编译 React/JSX 代码
* 使用 @babel/standalone 将 JSX 转换为可执行的 JavaScript
* Requirement 8.1, 14.4
*
* @param code - JSX 源代码
* @returns 编译后的代码字符串
*/
function compileJSX(code: string): string {
try {
const result = Babel.transform(code, {
presets: ["react"],
filename: "component.jsx",
});
if (!result.code) {
throw new Error("编译结果为空");
}
return result.code;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "编译失败";
console.error("[ReactRenderer] Error compiling JSX:", errorMessage, err);
throw err;
}
}
/**
* 从编译后的代码创建 React 组件
* 支持 App、Component 或默认导出的组件
* Requirement 8.2, 8.7, 14.4
*
* @param compiledCode - 编译后的 JavaScript 代码
* @returns React 组件
*/
function createComponentFromCode(compiledCode: string): React.ComponentType {
try {
// 创建一个安全的执行环境
// 提供基本的 React hooks 支持 (Requirement 8.7)
const createComponent = new Function(
"React",
"useState",
"useEffect",
"useMemo",
"useCallback",
"useRef",
`
${compiledCode}
// 尝试返回常见的组件导出名称
if (typeof App !== 'undefined') return App;
if (typeof Component !== 'undefined') return Component;
if (typeof Main !== 'undefined') return Main;
if (typeof default_1 !== 'undefined') return default_1;
// 如果没有找到组件,返回一个空组件
return function() { return null; };
`,
);
return createComponent(
React,
React.useState,
React.useEffect,
React.useMemo,
React.useCallback,
React.useRef,
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "组件创建失败";
console.error(
"[ReactRenderer] Error creating component from code:",
errorMessage,
err,
);
throw err;
}
}
/**
* React 渲染器组件
*
* 功能特性:
* - 使用 @babel/standalone 编译 React/JSX 代码 (Requirement 8.1)
* - 在沙箱化环境中渲染编译后的组件 (Requirement 8.2)
* - 提供实时预览,内容变化时自动更新 (Requirement 8.3 - 由 useEffect 实现)
* - 提供错误边界捕获渲染错误 (Requirement 8.4)
* - 编译失败时显示编译错误 (Requirement 8.5)
* - 提供预览/源码视图切换 (Requirement 8.6)
* - 支持基本 React hooks (useState, useEffect, useMemo) (Requirement 8.7)
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
*/
export const ReactRenderer: React.FC<ArtifactRendererProps> = memo(
({ artifact, isStreaming = false }) => {
// 视图模式状态
const [viewMode, setViewMode] = useState<ViewMode>("preview");
// 编译/渲染错误
const [error, setError] = useState<string | null>(null);
// 编译后的组件
const [Component, setComponent] = useState<React.ComponentType | null>(
null,
);
// 编译状态
const [isCompiling, setIsCompiling] = useState(false);
// 用于强制重新编译的 key
const [compileKey, setCompileKey] = useState(0);
/**
* 编译并创建组件
* Requirement 8.1, 8.2, 8.5
*/
useEffect(() => {
// 流式生成时不编译,等待内容完成
if (isStreaming) {
setComponent(null);
setError(null);
return;
}
// 内容为空时不编译
if (!artifact.content.trim()) {
setComponent(null);
setError(null);
return;
}
const compile = async () => {
setIsCompiling(true);
setError(null);
try {
// 编译 JSX (Requirement 8.1)
const compiledCode = compileJSX(artifact.content);
// 创建组件 (Requirement 8.2)
const Comp = createComponentFromCode(compiledCode);
setComponent(() => Comp);
setError(null);
} catch (e) {
// 编译失败时显示错误 (Requirement 8.5, 14.4)
const errorMessage = e instanceof Error ? e.message : "编译失败";
console.error(
"[ReactRenderer] Error compiling/creating component:",
errorMessage,
e,
);
console.error(
"[ReactRenderer] Failed content:",
artifact.content.substring(0, 200),
);
setError(errorMessage);
setComponent(null);
} finally {
setIsCompiling(false);
}
};
compile();
}, [artifact.content, isStreaming, compileKey]);
/**
* 重试编译
*/
const handleRetry = useCallback(() => {
setCompileKey((prev) => prev + 1);
}, []);
/**
* 处理渲染错误
* Requirement 14.4
*/
const handleRenderError = useCallback((err: Error) => {
const errorMessage = `渲染错误: ${err.message}`;
console.error("[ReactRenderer] Runtime render error:", errorMessage, err);
setError(errorMessage);
}, []);
/**
* 创建用于源码视图的 artifact 对象
*/
const sourceArtifact = useMemo(
() => ({
...artifact,
type: "code" as const,
meta: { ...artifact.meta, language: "jsx" },
}),
[artifact],
);
/**
* 渲染错误回退组件
*/
const errorFallback = useMemo(
() => (
<ErrorDisplay
message="组件渲染时发生错误"
source={artifact.content}
onRetry={handleRetry}
/>
),
[artifact.content, handleRetry],
);
return (
<div className="h-full flex flex-col bg-white rounded-lg overflow-hidden border border-gray-200">
{/* 工具栏 */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-gray-200 bg-gray-50">
{/* 视图模式切换 - Requirement 8.6 */}
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{/* 编译状态指示 */}
{isCompiling && (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>编译中...</span>
</div>
)}
{/* 重新编译按钮 */}
{viewMode === "preview" && !isStreaming && !isCompiling && (
<button
type="button"
onClick={handleRetry}
className={cn(
"inline-flex items-center justify-center w-8 h-8 rounded transition-all ml-auto",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
)}
title="重新编译"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-auto relative">
{viewMode === "preview" ? (
error ? (
/* 错误显示 - Requirement 8.5 */
<ErrorDisplay
message={error}
source={artifact.content}
onRetry={handleRetry}
/>
) : isStreaming ? (
/* 流式占位符 */
<StreamingPlaceholder />
) : isCompiling ? (
/* 编译中状态 */
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
</div>
) : Component ? (
/* 组件预览 - Requirement 8.2, 8.4 */
<div className="p-4 h-full">
<ErrorBoundary
fallback={errorFallback}
onError={handleRenderError}
>
<Component />
</ErrorBoundary>
</div>
) : (
/* 空内容状态 */
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Code2 className="w-12 h-12 text-gray-300 mb-4" />
<p className="text-sm text-gray-500">暂无组件内容</p>
</div>
)
) : (
/* 源码视图 - 复用 CodeRenderer - Requirement 8.6 */
<CodeRenderer artifact={sourceArtifact} isStreaming={isStreaming} />
)}
{/* 流式指示器 */}
{isStreaming && viewMode === "source" && <StreamingIndicator />}
</div>
</div>
);
},
);
ReactRenderer.displayName = "ReactRenderer";
export default ReactRenderer;
@@ -0,0 +1,407 @@
/**
* @file SVG 渲染器组件
* @description Artifact 系统的 SVG 渲染器,支持内联渲染、缩放控制、下载功能和预览/源码切换
* @module components/artifact/renderers/SvgRenderer
* @requirements 6.1, 6.2, 6.4, 6.5, 14.4
*/
import React, { useState, useRef, useCallback, useMemo, memo } from "react";
import {
Eye,
Code2,
Download,
ZoomIn,
ZoomOut,
Maximize2,
Loader2,
AlertCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ArtifactRendererProps } from "@/lib/artifact/types";
import { CodeRenderer } from "./CodeRenderer";
/**
* 视图模式类型
*/
type ViewMode = "preview" | "source";
/**
* 缩放级别配置
*/
const ZOOM_LEVELS = {
min: 0.25,
max: 4,
step: 0.25,
default: 1,
};
/**
* 流式指示器组件
*/
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";
/**
* 视图模式切换按钮组件
*/
interface ViewModeToggleProps {
value: ViewMode;
onChange: (value: ViewMode) => void;
}
const ViewModeToggle: React.FC<ViewModeToggleProps> = memo(
({ value, onChange }) => (
<div className="inline-flex items-center rounded-md bg-gray-100 p-1">
<button
type="button"
onClick={() => onChange("preview")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "preview"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="预览模式"
>
<Eye className="w-3.5 h-3.5" />
<span>预览</span>
</button>
<button
type="button"
onClick={() => onChange("source")}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all",
value === "source"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
)}
title="源码模式"
>
<Code2 className="w-3.5 h-3.5" />
<span>源码</span>
</button>
</div>
),
);
ViewModeToggle.displayName = "ViewModeToggle";
/**
* 缩放控制组件
*/
interface ZoomControlsProps {
zoom: number;
onZoomChange: (zoom: number) => void;
}
const ZoomControls: React.FC<ZoomControlsProps> = memo(
({ zoom, onZoomChange }) => {
const handleZoomIn = useCallback(() => {
onZoomChange(Math.min(zoom + ZOOM_LEVELS.step, ZOOM_LEVELS.max));
}, [zoom, onZoomChange]);
const handleZoomOut = useCallback(() => {
onZoomChange(Math.max(zoom - ZOOM_LEVELS.step, ZOOM_LEVELS.min));
}, [zoom, onZoomChange]);
const handleFitToView = useCallback(() => {
onZoomChange(ZOOM_LEVELS.default);
}, [onZoomChange]);
const zoomPercentage = Math.round(zoom * 100);
return (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={handleZoomOut}
disabled={zoom <= ZOOM_LEVELS.min}
className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
title="缩小"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="min-w-[3.5rem] text-center text-xs text-gray-600 font-medium">
{zoomPercentage}%
</span>
<button
type="button"
onClick={handleZoomIn}
disabled={zoom >= ZOOM_LEVELS.max}
className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
title="放大"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleFitToView}
className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
)}
title="适应视图"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
);
},
);
ZoomControls.displayName = "ZoomControls";
/**
* 下载按钮组件
*/
interface DownloadButtonProps {
onClick: () => void;
}
const DownloadButton: React.FC<DownloadButtonProps> = memo(({ onClick }) => (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center justify-center w-8 h-8 rounded transition-all",
"text-gray-500 hover:text-gray-700 hover:bg-gray-100",
)}
title="下载 SVG"
>
<Download className="w-4 h-4" />
</button>
));
DownloadButton.displayName = "DownloadButton";
/**
* 错误显示组件
*/
interface ErrorDisplayProps {
message: string;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = memo(({ message }) => (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">SVG 渲染失败</h3>
<p className="text-sm text-gray-500 max-w-md">{message}</p>
</div>
));
ErrorDisplay.displayName = "ErrorDisplay";
/**
* 验证 SVG 内容是否有效
* @param content - SVG 内容字符串
* @returns 是否为有效的 SVG
*/
function isValidSvg(content: string): boolean {
try {
if (!content || typeof content !== "string") {
console.error(
"[SvgRenderer] Error: SVG content is empty or not a string",
);
return false;
}
const trimmed = content.trim();
// 检查是否以 <svg 开头(允许有 XML 声明)
const isValid =
trimmed.startsWith("<svg") ||
trimmed.startsWith("<?xml") ||
trimmed.includes("<svg");
if (!isValid) {
console.error(
"[SvgRenderer] Error: Content does not appear to be valid SVG",
);
}
return isValid;
} catch (err) {
console.error("[SvgRenderer] Error validating SVG:", err);
return false;
}
}
/**
* 清理 SVG 内容,移除潜在的危险元素
* @param content - 原始 SVG 内容
* @returns 清理后的 SVG 内容
*/
function sanitizeSvg(content: string): string {
try {
// 移除 script 标签
let sanitized = content.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
"",
);
// 移除 on* 事件处理器
sanitized = sanitized.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, "");
return sanitized;
} catch (err) {
console.error("[SvgRenderer] Error sanitizing SVG:", err);
return content;
}
}
/**
* SVG 渲染器组件
*
* 功能特性:
* - 内联渲染 SVG 内容 (Requirement 6.1)
* - 提供缩放控制(放大、缩小、适应视图)(Requirement 6.2)
* - 提供下载为 SVG 文件功能 (Requirement 6.4)
* - 提供预览/源码视图切换 (Requirement 6.5)
*
* @param artifact - 要渲染的 Artifact 对象
* @param isStreaming - 是否处于流式生成状态
*/
export const SvgRenderer: React.FC<ArtifactRendererProps> = memo(
({ artifact, isStreaming = false }) => {
// 视图模式状态
const [viewMode, setViewMode] = useState<ViewMode>("preview");
// 缩放级别状态
const [zoom, setZoom] = useState(ZOOM_LEVELS.default);
// 容器引用
const containerRef = useRef<HTMLDivElement>(null);
/**
* 下载 SVG 文件
* Requirement 6.4
*/
const handleDownload = useCallback(() => {
try {
const blob = new Blob([artifact.content], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = artifact.meta.filename || "image.svg";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "下载失败";
console.error(
"[SvgRenderer] Error downloading SVG:",
errorMessage,
err,
);
}
}, [artifact.content, artifact.meta.filename]);
/**
* 验证并清理 SVG 内容
* Requirement 14.4
*/
const { isValid, sanitizedContent, errorMessage } = useMemo(() => {
try {
if (!isValidSvg(artifact.content)) {
const msg = "SVG 内容格式无效,请检查是否为有效的 SVG 代码";
console.error("[SvgRenderer] Error:", msg);
return {
isValid: false,
sanitizedContent: "",
errorMessage: msg,
};
}
return {
isValid: true,
sanitizedContent: sanitizeSvg(artifact.content),
errorMessage: null,
};
} catch (err) {
const msg = err instanceof Error ? err.message : "SVG 处理失败";
console.error("[SvgRenderer] Error processing SVG:", msg, err);
return {
isValid: false,
sanitizedContent: "",
errorMessage: msg,
};
}
}, [artifact.content]);
/**
* 创建用于源码视图的 artifact 对象
*/
const sourceArtifact = useMemo(
() => ({
...artifact,
type: "code" as const,
meta: { ...artifact.meta, language: "xml" },
}),
[artifact],
);
return (
<div className="h-full flex flex-col bg-white rounded-lg overflow-hidden border border-gray-200">
{/* 工具栏 */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-gray-200 bg-gray-50">
{/* 视图模式切换 */}
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{/* 缩放控制(仅在预览模式显示) */}
{viewMode === "preview" && (
<>
<div className="w-px h-5 bg-gray-300" />
<ZoomControls zoom={zoom} onZoomChange={setZoom} />
</>
)}
{/* 下载按钮 */}
<div className="ml-auto">
<DownloadButton onClick={handleDownload} />
</div>
</div>
{/* 内容区域 */}
<div
ref={containerRef}
className="flex-1 overflow-auto relative bg-gray-100"
>
{viewMode === "preview" ? (
isValid ? (
<div className="h-full flex items-center justify-center p-4">
{/*
SVG 内联渲染
- 使用 transform: scale() 实现缩放
- transformOrigin: center 确保从中心缩放
Requirement 6.1, 6.2
*/}
<div
className="transition-transform duration-200 ease-out"
style={{
transform: `scale(${zoom})`,
transformOrigin: "center",
}}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
</div>
) : (
/* 错误显示 - Requirement 6.6 */
<ErrorDisplay message={errorMessage || "SVG 渲染失败"} />
)
) : (
/* 源码视图 - 复用 CodeRenderer */
<CodeRenderer artifact={sourceArtifact} isStreaming={isStreaming} />
)}
{/* 流式指示器 */}
{isStreaming && <StreamingIndicator />}
</div>
</div>
);
},
);
SvgRenderer.displayName = "SvgRenderer";
export default SvgRenderer;
+148
View File
@@ -0,0 +1,148 @@
/**
* @file Artifact 渲染器导出入口
* @description 导出所有轻量渲染器并提供注册函数,将渲染器注册到 ArtifactRegistry
* @module components/artifact/renderers
* @requirements 3.1
*/
import { lazy } from "react";
import { artifactRegistry } from "@/lib/artifact/registry";
import type { RendererEntry, ArtifactType } from "@/lib/artifact/types";
// ============================================================================
// 渲染器组件导出
// ============================================================================
export { CodeRenderer } from "./CodeRenderer";
export { HtmlRenderer } from "./HtmlRenderer";
export { SvgRenderer } from "./SvgRenderer";
export { MermaidRenderer } from "./MermaidRenderer";
export { ReactRenderer } from "./ReactRenderer";
// ============================================================================
// 懒加载渲染器组件
// ============================================================================
/**
* 懒加载的代码渲染器
*/
const LazyCodeRenderer = lazy(() => import("./CodeRenderer"));
/**
* 懒加载的 HTML 渲染器
*/
const LazyHtmlRenderer = lazy(() => import("./HtmlRenderer"));
/**
* 懒加载的 SVG 渲染器
*/
const LazySvgRenderer = lazy(() => import("./SvgRenderer"));
/**
* 懒加载的 Mermaid 渲染器
*/
const LazyMermaidRenderer = lazy(() => import("./MermaidRenderer"));
/**
* 懒加载的 React 渲染器
*/
const LazyReactRenderer = lazy(() => import("./ReactRenderer"));
// ============================================================================
// 渲染器注册项定义
// ============================================================================
/**
* 轻量渲染器注册项列表
* 定义所有轻量类型 Artifact 的渲染器配置
*/
const LIGHTWEIGHT_RENDERER_ENTRIES: RendererEntry[] = [
{
type: "code" as ArtifactType,
displayName: "代码",
icon: "code",
component: LazyCodeRenderer,
canEdit: false,
fileExtension: "txt",
},
{
type: "html" as ArtifactType,
displayName: "HTML",
icon: "html",
component: LazyHtmlRenderer,
canEdit: false,
fileExtension: "html",
},
{
type: "svg" as ArtifactType,
displayName: "SVG",
icon: "image",
component: LazySvgRenderer,
canEdit: false,
fileExtension: "svg",
},
{
type: "mermaid" as ArtifactType,
displayName: "Mermaid 图表",
icon: "diagram",
component: LazyMermaidRenderer,
canEdit: false,
fileExtension: "mmd",
},
{
type: "react" as ArtifactType,
displayName: "React 组件",
icon: "react",
component: LazyReactRenderer,
canEdit: false,
fileExtension: "jsx",
},
];
// ============================================================================
// 注册函数
// ============================================================================
/**
* 注册所有轻量渲染器到 ArtifactRegistry
*
* 此函数应在应用初始化时调用,将所有轻量类型的渲染器注册到全局注册表中。
* 注册后,ArtifactRenderer 组件可以根据 Artifact 类型自动选择对应的渲染器。
*
* @requirements 3.1
*
* @example
* ```typescript
* // 在应用入口处调用
* import { registerLightweightRenderers } from '@/components/artifact/renderers';
*
* // 注册所有轻量渲染器
* registerLightweightRenderers();
* ```
*/
export function registerLightweightRenderers(): void {
for (const entry of LIGHTWEIGHT_RENDERER_ENTRIES) {
artifactRegistry.register(entry);
}
}
/**
* 获取所有轻量渲染器注册项
* 用于调试或自定义注册逻辑
*
* @returns 轻量渲染器注册项列表
*/
export function getLightweightRendererEntries(): RendererEntry[] {
return [...LIGHTWEIGHT_RENDERER_ENTRIES];
}
/**
* 检查轻量渲染器是否已注册
*
* @returns 是否所有轻量渲染器都已注册
*/
export function areLightweightRenderersRegistered(): boolean {
return LIGHTWEIGHT_RENDERER_ENTRIES.every((entry) =>
artifactRegistry.has(entry.type),
);
}
@@ -12,6 +12,10 @@ import { PosterCanvas } from "./poster";
import type { PosterCanvasState } from "./poster/types";
import { MusicCanvas } from "./music";
import type { MusicCanvasState } from "./music/types";
import { ScriptCanvas } from "./script";
import type { ScriptCanvasState } from "./script/types";
import { NovelCanvas } from "./novel";
import type { NovelCanvasState } from "./novel/types";
import { getCanvasTypeForTheme, type CanvasStateUnion } from "./canvasUtils";
/**
@@ -82,6 +86,26 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
);
}
if (canvasType === "script" && state.type === "script") {
return (
<ScriptCanvas
state={state}
onStateChange={onStateChange as (s: ScriptCanvasState) => void}
onClose={onClose}
/>
);
}
if (canvasType === "novel" && state.type === "novel") {
return (
<NovelCanvas
state={state}
onStateChange={onStateChange as (s: NovelCanvasState) => void}
onClose={onClose}
/>
);
}
// 不支持的主题或状态类型不匹配
return null;
},
@@ -0,0 +1,133 @@
/**
* @file 画布工具函数测试
* @description 测试画布相关的工具函数
* @module components/content-creator/canvas/canvasUtils.test
*/
import { describe, it, expect } from "vitest";
import {
getCanvasTypeForTheme,
isCanvasSupported,
createInitialCanvasState,
} from "./canvasUtils";
import type { ThemeType } from "../types";
// ============================================================================
// getCanvasTypeForTheme 测试
// ============================================================================
describe("getCanvasTypeForTheme", () => {
it("应该为每种主题返回正确的画布类型", () => {
expect(getCanvasTypeForTheme("video")).toBe("script");
expect(getCanvasTypeForTheme("novel")).toBe("novel");
expect(getCanvasTypeForTheme("poster")).toBe("poster");
expect(getCanvasTypeForTheme("music")).toBe("music");
expect(getCanvasTypeForTheme("social-media")).toBe("document");
expect(getCanvasTypeForTheme("document")).toBe("document");
expect(getCanvasTypeForTheme("general")).toBeNull();
expect(getCanvasTypeForTheme("knowledge")).toBeNull();
expect(getCanvasTypeForTheme("planning")).toBeNull();
});
it("应该覆盖所有 ThemeType", () => {
const allThemes: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
allThemes.forEach((theme) => {
// 不应该抛出错误
expect(() => getCanvasTypeForTheme(theme)).not.toThrow();
});
});
});
// ============================================================================
// isCanvasSupported 测试
// ============================================================================
describe("isCanvasSupported", () => {
it("应该正确判断主题是否支持画布", () => {
expect(isCanvasSupported("video")).toBe(true);
expect(isCanvasSupported("novel")).toBe(true);
expect(isCanvasSupported("poster")).toBe(true);
expect(isCanvasSupported("music")).toBe(true);
expect(isCanvasSupported("social-media")).toBe(true);
expect(isCanvasSupported("document")).toBe(true);
expect(isCanvasSupported("general")).toBe(false);
expect(isCanvasSupported("knowledge")).toBe(false);
expect(isCanvasSupported("planning")).toBe(false);
});
it("支持画布的主题数量应该是 6 种", () => {
const allThemes: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
const supportedCount = allThemes.filter((theme) =>
isCanvasSupported(theme),
).length;
expect(supportedCount).toBe(6);
});
});
// ============================================================================
// createInitialCanvasState 测试
// ============================================================================
describe("createInitialCanvasState", () => {
it("应该为支持画布的主题创建初始状态", () => {
const docState = createInitialCanvasState("document", "test content");
expect(docState).not.toBeNull();
expect(docState?.type).toBe("document");
const novelState = createInitialCanvasState("novel", "test content");
expect(novelState).not.toBeNull();
expect(novelState?.type).toBe("novel");
const scriptState = createInitialCanvasState("video", "test content");
expect(scriptState).not.toBeNull();
expect(scriptState?.type).toBe("script");
const posterState = createInitialCanvasState("poster");
expect(posterState).not.toBeNull();
expect(posterState?.type).toBe("poster");
const musicState = createInitialCanvasState("music");
expect(musicState).not.toBeNull();
expect(musicState?.type).toBe("music");
const socialState = createInitialCanvasState("social-media", "test");
expect(socialState).not.toBeNull();
expect(socialState?.type).toBe("document");
});
it("不支持画布的主题应该返回 null", () => {
expect(createInitialCanvasState("general", "test")).toBeNull();
expect(createInitialCanvasState("knowledge", "test")).toBeNull();
expect(createInitialCanvasState("planning", "test")).toBeNull();
});
it("应该正确处理空内容参数", () => {
const docState = createInitialCanvasState("document");
expect(docState).not.toBeNull();
const novelState = createInitialCanvasState("novel");
expect(novelState).not.toBeNull();
});
});
@@ -11,6 +11,10 @@ import { createInitialPosterState } from "./poster";
import type { PosterCanvasState } from "./poster/types";
import { createInitialMusicState } from "./music";
import type { MusicCanvasState } from "./music/types";
import { createInitialScriptState } from "./script";
import type { ScriptCanvasState } from "./script/types";
import { createInitialNovelState } from "./novel";
import type { NovelCanvasState } from "./novel/types";
/**
* 画布状态联合类型
@@ -18,28 +22,29 @@ import type { MusicCanvasState } from "./music/types";
export type CanvasStateUnion =
| DocumentCanvasState
| PosterCanvasState
| MusicCanvasState;
| MusicCanvasState
| ScriptCanvasState
| NovelCanvasState;
/**
* 画布类型
*/
export type CanvasType = "document" | "poster" | "music";
export type CanvasType = "document" | "poster" | "music" | "script" | "novel";
/**
* 主题到画布类型的映射
* 与 ProjectType 统一后的配置
*/
const THEME_TO_CANVAS_TYPE: Record<ThemeType, CanvasType | null> = {
general: null,
knowledge: null,
planning: null,
"social-media": "document",
poster: "poster",
document: "document",
paper: "document",
novel: "document",
script: "document",
music: "music",
video: null,
knowledge: null,
planning: null,
document: "document",
video: "script",
novel: "novel",
};
/**
@@ -72,6 +77,10 @@ export function createInitialCanvasState(
return createInitialPosterState();
case "music":
return createInitialMusicState();
case "script":
return createInitialScriptState(content);
case "novel":
return createInitialNovelState(content);
default:
return null;
}
@@ -0,0 +1,293 @@
/**
* 小说画布组件
*
* 用于小说项目的章节编辑
*/
import React, { memo, useCallback } from "react";
import styled from "styled-components";
import { X, Plus, FileText, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { NovelCanvasState, Chapter } from "./types";
import { countWords } from "./types";
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid hsl(var(--border));
`;
const Title = styled.h3`
font-size: 14px;
font-weight: 600;
`;
const Content = styled.div`
display: flex;
flex: 1;
min-height: 0;
`;
const ChapterList = styled.div`
width: 220px;
border-right: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
`;
const ChapterListHeader = styled.div`
padding: 12px;
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
`;
const ChapterItem = styled.div<{ $active?: boolean }>`
padding: 12px;
cursor: pointer;
border-bottom: 1px solid hsl(var(--border));
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
&:hover {
background: hsl(var(--accent));
}
`;
const ChapterTitle = styled.div`
font-weight: 500;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
`;
const ChapterMeta = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-top: 4px;
`;
const EditorArea = styled.div`
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
`;
const ChapterHeader = styled.div`
padding: 16px;
border-bottom: 1px solid hsl(var(--border));
display: flex;
gap: 12px;
align-items: center;
`;
const EditorContainer = styled.div`
flex: 1;
padding: 24px;
display: flex;
flex-direction: column;
`;
const Editor = styled(Textarea)`
flex: 1;
min-height: 400px;
font-size: 16px;
line-height: 1.8;
resize: none;
border: none;
background: transparent;
&:focus {
outline: none;
box-shadow: none;
}
`;
const StatusBar = styled.div`
padding: 8px 16px;
border-top: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
interface NovelCanvasProps {
state: NovelCanvasState;
onStateChange: (state: NovelCanvasState) => void;
onClose: () => void;
}
export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
({ state, onStateChange, onClose }) => {
const currentChapter = state.chapters.find(
(c) => c.id === state.currentChapterId,
);
const handleChapterSelect = useCallback(
(chapterId: string) => {
onStateChange({ ...state, currentChapterId: chapterId });
},
[state, onStateChange],
);
const handleAddChapter = useCallback(() => {
const now = Date.now();
const newChapter: Chapter = {
id: crypto.randomUUID(),
number: state.chapters.length + 1,
title: `第${state.chapters.length + 1}章`,
content: "",
wordCount: 0,
status: "draft",
createdAt: now,
updatedAt: now,
};
onStateChange({
...state,
chapters: [...state.chapters, newChapter],
currentChapterId: newChapter.id,
});
}, [state, onStateChange]);
const handleUpdateChapter = useCallback(
(updates: Partial<Chapter>) => {
if (!currentChapter) return;
const now = Date.now();
const updatedChapters = state.chapters.map((c) =>
c.id === currentChapter.id
? {
...c,
...updates,
wordCount: updates.content
? countWords(updates.content)
: c.wordCount,
updatedAt: now,
}
: c,
);
onStateChange({ ...state, chapters: updatedChapters });
},
[state, currentChapter, onStateChange],
);
const totalWords = state.chapters.reduce((sum, c) => sum + c.wordCount, 0);
const completedCount = state.chapters.filter(
(c) => c.status === "completed",
).length;
return (
<Container>
<Header>
<Title>小说编辑器</Title>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</Header>
<Content>
<ChapterList>
<ChapterListHeader>
<span className="text-sm font-medium">章节</span>
<Button variant="ghost" size="icon" onClick={handleAddChapter}>
<Plus className="h-4 w-4" />
</Button>
</ChapterListHeader>
<ScrollArea className="flex-1">
{state.chapters.map((chapter) => (
<ChapterItem
key={chapter.id}
$active={chapter.id === state.currentChapterId}
onClick={() => handleChapterSelect(chapter.id)}
>
<ChapterTitle>
{chapter.status === "completed" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
)}
{chapter.title}
</ChapterTitle>
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
</ChapterItem>
))}
</ScrollArea>
</ChapterList>
<EditorArea>
{currentChapter && (
<>
<ChapterHeader>
<Input
value={currentChapter.title}
onChange={(e) =>
handleUpdateChapter({ title: e.target.value })
}
placeholder="章节标题"
className="text-lg font-medium"
/>
<Button
variant={
currentChapter.status === "completed"
? "secondary"
: "outline"
}
size="sm"
onClick={() =>
handleUpdateChapter({
status:
currentChapter.status === "completed"
? "draft"
: "completed",
})
}
>
{currentChapter.status === "completed"
? "已完成"
: "标记完成"}
</Button>
</ChapterHeader>
<EditorContainer>
<Editor
value={currentChapter.content}
onChange={(e) =>
handleUpdateChapter({ content: e.target.value })
}
placeholder="开始写作..."
/>
</EditorContainer>
</>
)}
</EditorArea>
</Content>
<StatusBar>
<span>
{completedCount}/{state.chapters.length} 章完成
</span>
<span>总字数:{totalWords.toLocaleString()}</span>
</StatusBar>
</Container>
);
},
);
NovelCanvas.displayName = "NovelCanvas";
@@ -0,0 +1,7 @@
/**
* 小说画布模块导出
*/
export { NovelCanvas } from "./NovelCanvas";
export type { NovelCanvasState, Chapter, OutlineNode } from "./types";
export { createInitialNovelState, countWords, novelStateToText } from "./types";
@@ -0,0 +1,82 @@
/**
* 小说画布类型定义
*/
/** 章节 */
export interface Chapter {
id: string;
number: number;
title: string;
content: string;
wordCount: number;
status: "draft" | "completed";
createdAt: number;
updatedAt: number;
}
/** 大纲节点 */
export interface OutlineNode {
id: string;
title: string;
content?: string;
children: OutlineNode[];
expanded: boolean;
}
/** 小说画布状态 */
export interface NovelCanvasState {
type: "novel";
chapters: Chapter[];
currentChapterId: string;
outline: OutlineNode[];
title?: string;
synopsis?: string;
}
/** 创建初始小说状态 */
export function createInitialNovelState(content?: string): NovelCanvasState {
const now = Date.now();
const defaultChapter: Chapter = {
id: crypto.randomUUID(),
number: 1,
title: "第一章",
content: content || "",
wordCount: content ? content.length : 0,
status: "draft",
createdAt: now,
updatedAt: now,
};
return {
type: "novel",
chapters: [defaultChapter],
currentChapterId: defaultChapter.id,
outline: [],
};
}
/** 计算字数 */
export function countWords(text: string): number {
// 简单的中文字数统计
return text.replace(/\s/g, "").length;
}
/** 将小说状态转换为文本 */
export function novelStateToText(state: NovelCanvasState): string {
let text = "";
if (state.title) {
text += `# ${state.title}\n\n`;
}
if (state.synopsis) {
text += `> ${state.synopsis}\n\n`;
}
for (const chapter of state.chapters) {
text += `## ${chapter.title}\n\n`;
text += `${chapter.content}\n\n`;
}
return text;
}
@@ -0,0 +1,318 @@
/**
* 剧本画布组件
*
* 用于短剧项目的剧本编辑
*/
import React, { memo, useCallback } from "react";
import styled from "styled-components";
import { X, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ScriptCanvasState, Scene, Dialogue } from "./types";
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid hsl(var(--border));
`;
const Title = styled.h3`
font-size: 14px;
font-weight: 600;
`;
const Content = styled.div`
display: flex;
flex: 1;
min-height: 0;
`;
const SceneList = styled.div`
width: 200px;
border-right: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
`;
const SceneListHeader = styled.div`
padding: 12px;
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
`;
const SceneItem = styled.div<{ $active?: boolean }>`
padding: 12px;
cursor: pointer;
border-bottom: 1px solid hsl(var(--border));
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
&:hover {
background: hsl(var(--accent));
}
`;
const SceneNumber = styled.div`
font-weight: 600;
font-size: 13px;
`;
const SceneLocation = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const EditorArea = styled.div`
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
`;
const SceneHeader = styled.div`
padding: 16px;
border-bottom: 1px solid hsl(var(--border));
display: flex;
gap: 12px;
align-items: center;
`;
const DialogueList = styled.div`
flex: 1;
padding: 16px;
`;
const DialogueItem = styled.div`
margin-bottom: 16px;
padding: 12px;
background: hsl(var(--card));
border-radius: 8px;
border: 1px solid hsl(var(--border));
`;
const DialogueHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
`;
const _CharacterName = styled.div`
font-weight: 600;
font-size: 14px;
color: hsl(var(--primary));
`;
interface ScriptCanvasProps {
state: ScriptCanvasState;
onStateChange: (state: ScriptCanvasState) => void;
onClose: () => void;
}
export const ScriptCanvas: React.FC<ScriptCanvasProps> = memo(
({ state, onStateChange, onClose }) => {
const currentScene = state.scenes.find(
(s) => s.id === state.currentSceneId,
);
const handleSceneSelect = useCallback(
(sceneId: string) => {
onStateChange({ ...state, currentSceneId: sceneId });
},
[state, onStateChange],
);
const handleAddScene = useCallback(() => {
const newScene: Scene = {
id: crypto.randomUUID(),
number: state.scenes.length + 1,
location: "内景",
time: "日",
dialogues: [],
};
onStateChange({
...state,
scenes: [...state.scenes, newScene],
currentSceneId: newScene.id,
});
}, [state, onStateChange]);
const handleUpdateScene = useCallback(
(updates: Partial<Scene>) => {
if (!currentScene) return;
const updatedScenes = state.scenes.map((s) =>
s.id === currentScene.id ? { ...s, ...updates } : s,
);
onStateChange({ ...state, scenes: updatedScenes });
},
[state, currentScene, onStateChange],
);
const handleAddDialogue = useCallback(() => {
if (!currentScene) return;
const newDialogue: Dialogue = {
id: crypto.randomUUID(),
characterId: "",
characterName: "角色",
content: "",
};
handleUpdateScene({
dialogues: [...currentScene.dialogues, newDialogue],
});
}, [currentScene, handleUpdateScene]);
const handleUpdateDialogue = useCallback(
(dialogueId: string, updates: Partial<Dialogue>) => {
if (!currentScene) return;
const updatedDialogues = currentScene.dialogues.map((d) =>
d.id === dialogueId ? { ...d, ...updates } : d,
);
handleUpdateScene({ dialogues: updatedDialogues });
},
[currentScene, handleUpdateScene],
);
const handleDeleteDialogue = useCallback(
(dialogueId: string) => {
if (!currentScene) return;
const updatedDialogues = currentScene.dialogues.filter(
(d) => d.id !== dialogueId,
);
handleUpdateScene({ dialogues: updatedDialogues });
},
[currentScene, handleUpdateScene],
);
return (
<Container>
<Header>
<Title>剧本编辑器</Title>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</Header>
<Content>
<SceneList>
<SceneListHeader>
<span className="text-sm font-medium">场景</span>
<Button variant="ghost" size="icon" onClick={handleAddScene}>
<Plus className="h-4 w-4" />
</Button>
</SceneListHeader>
<ScrollArea className="flex-1">
{state.scenes.map((scene) => (
<SceneItem
key={scene.id}
$active={scene.id === state.currentSceneId}
onClick={() => handleSceneSelect(scene.id)}
>
<SceneNumber>第{scene.number}场</SceneNumber>
<SceneLocation>
{scene.location}({scene.time})
</SceneLocation>
</SceneItem>
))}
</ScrollArea>
</SceneList>
<EditorArea>
{currentScene && (
<>
<SceneHeader>
<Input
value={currentScene.location}
onChange={(e) =>
handleUpdateScene({ location: e.target.value })
}
placeholder="场景地点"
className="w-40"
/>
<Input
value={currentScene.time}
onChange={(e) =>
handleUpdateScene({ time: e.target.value })
}
placeholder="时间"
className="w-20"
/>
<Textarea
value={currentScene.description || ""}
onChange={(e) =>
handleUpdateScene({ description: e.target.value })
}
placeholder="场景描述..."
className="flex-1 min-h-[40px] resize-none"
/>
</SceneHeader>
<ScrollArea className="flex-1">
<DialogueList>
{currentScene.dialogues.map((dialogue) => (
<DialogueItem key={dialogue.id}>
<DialogueHeader>
<Input
value={dialogue.characterName}
onChange={(e) =>
handleUpdateDialogue(dialogue.id, {
characterName: e.target.value,
})
}
placeholder="角色名"
className="w-32"
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteDialogue(dialogue.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogueHeader>
<Textarea
value={dialogue.content}
onChange={(e) =>
handleUpdateDialogue(dialogue.id, {
content: e.target.value,
})
}
placeholder="对白内容..."
className="min-h-[60px]"
/>
</DialogueItem>
))}
<Button
variant="outline"
className="w-full"
onClick={handleAddDialogue}
>
<Plus className="h-4 w-4 mr-2" />
添加对白
</Button>
</DialogueList>
</ScrollArea>
</>
)}
</EditorArea>
</Content>
</Container>
);
},
);
ScriptCanvas.displayName = "ScriptCanvas";
@@ -0,0 +1,7 @@
/**
* 剧本画布模块导出
*/
export { ScriptCanvas } from "./ScriptCanvas";
export type { ScriptCanvasState, Scene, Dialogue } from "./types";
export { createInitialScriptState, scriptStateToText } from "./types";
@@ -0,0 +1,142 @@
/**
* 剧本画布类型定义
*/
/** 对白 */
export interface Dialogue {
id: string;
characterId: string;
characterName: string;
content: string;
direction?: string; // 表演指示
emotion?: string;
}
/** 场景 */
export interface Scene {
id: string;
number: number;
location: string;
time: string; // 日/夜/晨/昏
description?: string;
dialogues: Dialogue[];
}
/** 剧本画布状态 */
export interface ScriptCanvasState {
type: "script";
scenes: Scene[];
currentSceneId: string;
title?: string;
synopsis?: string;
}
/** 创建初始剧本状态 */
export function createInitialScriptState(content?: string): ScriptCanvasState {
const defaultScene: Scene = {
id: crypto.randomUUID(),
number: 1,
location: "内景",
time: "日",
description: "",
dialogues: [],
};
// 如果有内容,尝试解析
if (content) {
const scenes = parseScriptContent(content);
if (scenes.length > 0) {
return {
type: "script",
scenes,
currentSceneId: scenes[0].id,
};
}
}
return {
type: "script",
scenes: [defaultScene],
currentSceneId: defaultScene.id,
};
}
/** 解析剧本内容 */
function parseScriptContent(content: string): Scene[] {
const scenes: Scene[] = [];
const lines = content.split("\n");
let currentScene: Scene | null = null;
for (const line of lines) {
const trimmed = line.trim();
// 场景标题:## 第X场 或 场景X
const sceneMatch = trimmed.match(
/^(?:##\s*)?(?:第(\d+)场|场景\s*(\d+))[::]\s*(.+)?/,
);
if (sceneMatch) {
if (currentScene) {
scenes.push(currentScene);
}
const sceneNum = parseInt(sceneMatch[1] || sceneMatch[2]);
currentScene = {
id: crypto.randomUUID(),
number: sceneNum,
location: sceneMatch[3] || "内景",
time: "日",
dialogues: [],
};
continue;
}
// 对白:角色名:内容
const dialogueMatch = trimmed.match(/^([^::]+)[::]\s*(.+)/);
if (dialogueMatch && currentScene) {
currentScene.dialogues.push({
id: crypto.randomUUID(),
characterId: "",
characterName: dialogueMatch[1].trim(),
content: dialogueMatch[2].trim(),
});
}
}
if (currentScene) {
scenes.push(currentScene);
}
return scenes;
}
/** 将剧本状态转换为文本 */
export function scriptStateToText(state: ScriptCanvasState): string {
let text = "";
if (state.title) {
text += `# ${state.title}\n\n`;
}
if (state.synopsis) {
text += `${state.synopsis}\n\n`;
}
for (const scene of state.scenes) {
text += `## 第${scene.number}场:${scene.location}(${scene.time})\n\n`;
if (scene.description) {
text += `*${scene.description}*\n\n`;
}
for (const dialogue of scene.dialogues) {
if (dialogue.direction) {
text += `(${dialogue.direction})\n`;
}
text += `${dialogue.characterName}:${dialogue.content}\n`;
}
text += "\n";
}
return text;
}
@@ -119,10 +119,10 @@ export function useLayoutTransition(
};
}
// chat 区域 - 左边 30%
// chat 区域 - 左边 40%(画布打开时)
return {
transition: `width ${duration}ms ease-out`,
width: mode === "chat-canvas" ? "30%" : "100%",
width: mode === "chat-canvas" ? "40%" : "100%",
};
},
[transitionState, mergedConfig, mode],
@@ -0,0 +1,356 @@
/**
* @file useWorkflow 步骤定义测试
* @description 测试工作流步骤定义与文件映射的一致性
* @module components/content-creator/hooks/useWorkflow.test
*/
import { describe, it, expect } from "vitest";
import { getWorkflowSteps } from "./useWorkflow";
import { getFileToStepMap } from "@/components/agent/chat/utils/workflowMapping";
import type { ThemeType, CreationMode } from "../types";
// ============================================================================
// getWorkflowSteps 基本功能测试
// ============================================================================
describe("getWorkflowSteps", () => {
describe("social-media 主题", () => {
it("guided 模式应该返回 4 个步骤", () => {
const steps = getWorkflowSteps("social-media", "guided");
expect(steps.length).toBe(4);
});
it("fast 模式应该返回 3 个步骤", () => {
const steps = getWorkflowSteps("social-media", "fast");
expect(steps.length).toBe(3);
});
it("guided 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("social-media", "guided");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual(["brief", "create", "polish", "adapt"]);
});
it("fast 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("social-media", "fast");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual(["brief", "create", "adapt"]);
});
});
describe("video 主题", () => {
it("guided 模式应该返回 5 个步骤", () => {
const steps = getWorkflowSteps("video", "guided");
expect(steps.length).toBe(5);
});
it("fast 模式应该返回 3 个步骤", () => {
const steps = getWorkflowSteps("video", "fast");
expect(steps.length).toBe(3);
});
it("guided 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("video", "guided");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual([
"brief",
"outline",
"storyboard",
"script",
"polish",
]);
});
it("fast 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("video", "fast");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual(["brief", "script", "polish"]);
});
});
describe("novel 主题", () => {
it("guided 模式应该返回 5 个步骤", () => {
const steps = getWorkflowSteps("novel", "guided");
expect(steps.length).toBe(5);
});
it("fast 模式应该返回 3 个步骤", () => {
const steps = getWorkflowSteps("novel", "fast");
expect(steps.length).toBe(3);
});
it("guided 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("novel", "guided");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual([
"brief",
"outline",
"character",
"write",
"polish",
]);
});
});
describe("document 主题", () => {
it("guided 模式应该返回 4 个步骤", () => {
const steps = getWorkflowSteps("document", "guided");
expect(steps.length).toBe(4);
});
it("fast 模式应该返回 3 个步骤", () => {
const steps = getWorkflowSteps("document", "fast");
expect(steps.length).toBe(3);
});
it("guided 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("document", "guided");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual(["brief", "outline", "write", "polish"]);
});
});
describe("music 主题", () => {
it("guided 模式应该返回 7 个步骤", () => {
const steps = getWorkflowSteps("music", "guided");
expect(steps.length).toBe(7);
});
it("fast 模式应该返回 3 个步骤", () => {
const steps = getWorkflowSteps("music", "fast");
expect(steps.length).toBe(3);
});
it("guided 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("music", "guided");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual([
"spec",
"theme",
"mood",
"structure",
"lyrics",
"polish",
"export",
]);
});
it("fast 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("music", "fast");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual(["spec", "lyrics", "export"]);
});
});
describe("poster 主题", () => {
it("guided 模式应该返回 5 个步骤", () => {
const steps = getWorkflowSteps("poster", "guided");
expect(steps.length).toBe(5);
});
it("fast 模式应该返回 3 个步骤", () => {
const steps = getWorkflowSteps("poster", "fast");
expect(steps.length).toBe(3);
});
it("guided 模式应该有正确的步骤 ID", () => {
const steps = getWorkflowSteps("poster", "guided");
const stepIds = steps.map((s) => s.id);
expect(stepIds).toEqual([
"brief",
"copywriting",
"layout",
"design",
"export",
]);
});
});
describe("无工作流的主题", () => {
it("general 应该返回空数组", () => {
const steps = getWorkflowSteps("general", "guided");
expect(steps.length).toBe(0);
});
it("knowledge 应该返回空数组", () => {
const steps = getWorkflowSteps("knowledge", "guided");
expect(steps.length).toBe(0);
});
it("planning 应该返回空数组", () => {
const steps = getWorkflowSteps("planning", "guided");
expect(steps.length).toBe(0);
});
});
});
// ============================================================================
// 步骤定义与文件映射一致性测试
// ============================================================================
describe("getWorkflowSteps 与 getFileToStepMap 一致性", () => {
const themesWithWorkflow: ThemeType[] = [
"social-media",
"video",
"novel",
"document",
"music",
"poster",
];
const modes: CreationMode[] = ["guided", "fast"];
describe("文件映射索引不超过步骤数量", () => {
themesWithWorkflow.forEach((theme) => {
modes.forEach((mode) => {
it(`${theme} (${mode}) 的文件映射最大索引应该 < 步骤数量`, () => {
const steps = getWorkflowSteps(theme, mode);
const fileMap = getFileToStepMap(theme);
const maxFileIndex = Math.max(...Object.values(fileMap), -1);
// 文件映射的最大索引应该小于步骤数量
// 注意:快速模式可能有更少的步骤,但文件映射是通用的
// 所以我们只在 guided 模式下检查
if (mode === "guided" && steps.length > 0) {
expect(maxFileIndex).toBeLessThan(steps.length);
}
});
});
});
});
describe("无工作流主题的一致性", () => {
const themesWithoutWorkflow: ThemeType[] = [
"general",
"knowledge",
"planning",
];
themesWithoutWorkflow.forEach((theme) => {
it(`${theme} 步骤和文件映射都应该为空`, () => {
const steps = getWorkflowSteps(theme, "guided");
const fileMap = getFileToStepMap(theme);
expect(steps.length).toBe(0);
expect(Object.keys(fileMap).length).toBe(0);
});
});
});
});
// ============================================================================
// 步骤属性测试
// ============================================================================
describe("步骤属性", () => {
describe("第一步应该不可跳过", () => {
const themesWithWorkflow: ThemeType[] = [
"social-media",
"video",
"novel",
"document",
"music",
"poster",
];
themesWithWorkflow.forEach((theme) => {
it(`${theme} 的第一步应该不可跳过`, () => {
const steps = getWorkflowSteps(theme, "guided");
if (steps.length > 0) {
expect(steps[0].behavior.skippable).toBe(false);
}
});
});
});
describe("所有步骤应该有必要的属性", () => {
const themesWithWorkflow: ThemeType[] = [
"social-media",
"video",
"novel",
"document",
"music",
"poster",
];
themesWithWorkflow.forEach((theme) => {
it(`${theme} 的所有步骤应该有 id, type, title, behavior`, () => {
const steps = getWorkflowSteps(theme, "guided");
steps.forEach((step) => {
expect(step.id).toBeDefined();
expect(step.type).toBeDefined();
expect(step.title).toBeDefined();
expect(step.behavior).toBeDefined();
expect(step.behavior.skippable).toBeDefined();
expect(step.behavior.redoable).toBeDefined();
expect(step.behavior.autoAdvance).toBeDefined();
});
});
});
});
describe("步骤 ID 应该唯一", () => {
const themesWithWorkflow: ThemeType[] = [
"social-media",
"video",
"novel",
"document",
"music",
"poster",
];
themesWithWorkflow.forEach((theme) => {
it(`${theme} 的步骤 ID 应该唯一`, () => {
const steps = getWorkflowSteps(theme, "guided");
const ids = steps.map((s) => s.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
});
});
});
// ============================================================================
// 覆盖所有主题类型测试
// ============================================================================
describe("覆盖所有主题类型", () => {
it("应该覆盖所有 ThemeType", () => {
const allThemes: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
allThemes.forEach((theme) => {
// 不应该抛出错误
expect(() => getWorkflowSteps(theme, "guided")).not.toThrow();
expect(() => getWorkflowSteps(theme, "fast")).not.toThrow();
});
});
it("有工作流的主题数量应该是 6 种", () => {
const allThemes: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
const themesWithWorkflow = allThemes.filter(
(theme) => getWorkflowSteps(theme, "guided").length > 0,
);
expect(themesWithWorkflow.length).toBe(6);
});
});
@@ -110,6 +110,306 @@ function getMusicWorkflowSteps(mode: CreationMode): StepDefinition[] {
];
}
/**
* 获取社媒内容创作工作流步骤
*/
function getSocialMediaWorkflowSteps(mode: CreationMode): StepDefinition[] {
// 快速模式:3 步骤(明确需求 → 生成内容 → 平台适配)
if (mode === "fast") {
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义内容主题、平台和风格",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "create",
type: "write",
title: "生成内容",
description: "AI 生成社媒内容",
aiTask: { taskType: "create", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "adapt",
type: "adapt",
title: "平台适配",
description: "适配目标平台格式",
aiTask: { taskType: "adapt", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
// 引导模式:4 步骤
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义内容主题、目标受众和平台",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "create",
type: "write",
title: "创作内容",
description: "AI 生成社媒文案",
aiTask: { taskType: "create", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化文案表达和吸引力",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
{
id: "adapt",
type: "adapt",
title: "平台适配",
description: "适配不同平台的格式要求",
aiTask: { taskType: "adapt", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
/**
* 获取视频脚本创作工作流步骤
*/
function getVideoWorkflowSteps(mode: CreationMode): StepDefinition[] {
// 快速模式:3 步骤(明确需求 → 生成剧本 → 润色优化)
if (mode === "fast") {
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义视频主题、时长和风格",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "script",
type: "write",
title: "生成剧本",
description: "AI 生成视频脚本",
aiTask: { taskType: "script", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化剧本内容",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
// 引导模式:5 步骤
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义视频主题、时长和目标受众",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "outline",
type: "outline",
title: "剧情大纲",
description: "规划视频整体结构和节奏",
aiTask: { taskType: "outline", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "storyboard",
type: "research",
title: "分镜设计",
description: "设计关键画面和镜头",
aiTask: { taskType: "storyboard", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
{
id: "script",
type: "write",
title: "撰写剧本",
description: "撰写完整视频脚本",
aiTask: { taskType: "script", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化台词和节奏",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
/**
* 获取小说创作工作流步骤
*/
function getNovelWorkflowSteps(mode: CreationMode): StepDefinition[] {
// 快速模式:3 步骤(明确需求 → 生成章节 → 润色优化)
if (mode === "fast") {
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义故事主题、类型和风格",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "write",
type: "write",
title: "生成章节",
description: "AI 生成小说内容",
aiTask: { taskType: "write", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化文笔和情节",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
// 引导模式:5 步骤
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义故事主题、类型和目标读者",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "outline",
type: "outline",
title: "章节大纲",
description: "规划故事结构和章节",
aiTask: { taskType: "outline", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "character",
type: "research",
title: "角色设定",
description: "设计主要角色和背景",
aiTask: { taskType: "character", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
{
id: "write",
type: "write",
title: "撰写内容",
description: "撰写小说章节",
aiTask: { taskType: "write", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化文笔和情节连贯性",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
/**
* 获取文档创作工作流步骤
*/
function getDocumentWorkflowSteps(mode: CreationMode): StepDefinition[] {
// 快速模式:3 步骤(明确需求 → 生成文档 → 润色优化)
if (mode === "fast") {
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义文档主题、类型和受众",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "write",
type: "write",
title: "生成文档",
description: "AI 生成文档内容",
aiTask: { taskType: "write", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化文档结构和表达",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
// 引导模式:4 步骤
return [
{
id: "brief",
type: "clarify",
title: "明确需求",
description: "定义文档主题、类型和目标读者",
aiTask: { taskType: "brief", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "outline",
type: "outline",
title: "文档大纲",
description: "规划文档结构和章节",
aiTask: { taskType: "outline", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "write",
type: "write",
title: "撰写内容",
description: "撰写文档正文",
aiTask: { taskType: "write", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "优化结构和语言表达",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
/**
* 获取海报创作工作流步骤
*/
@@ -191,190 +491,37 @@ function getPosterWorkflowSteps(mode: CreationMode): StepDefinition[] {
/**
* 获取主题对应的工作流步骤
* 导出供测试使用
*/
function getWorkflowSteps(
export function getWorkflowSteps(
theme: ThemeType,
mode: CreationMode,
): StepDefinition[] {
// 通用对话不需要工作流
if (theme === "general") {
return [];
switch (theme) {
// 不显示进度条的类型
case "general":
case "knowledge":
case "planning":
return [];
// 有专用工作流的类型
case "music":
return getMusicWorkflowSteps(mode);
case "poster":
return getPosterWorkflowSteps(mode);
case "social-media":
return getSocialMediaWorkflowSteps(mode);
case "video":
return getVideoWorkflowSteps(mode);
case "novel":
return getNovelWorkflowSteps(mode);
case "document":
return getDocumentWorkflowSteps(mode);
// 未知类型不显示进度条
default:
return [];
}
// 音乐主题:使用专门的音乐创作步骤
if (theme === "music") {
return getMusicWorkflowSteps(mode);
}
// 海报主题:使用专门的海报创作步骤
if (theme === "poster") {
return getPosterWorkflowSteps(mode);
}
// 快速模式:简化步骤(收集需求 → 生成初稿 → 迭代修改)
if (mode === "fast") {
return [
{
id: "clarify",
type: "clarify",
title: "明确需求",
description: "填写创作主题和要求",
form: {
fields: [
{ name: "topic", label: "内容主题", type: "text", required: true },
{
name: "keyPoints",
label: "核心要点",
type: "text",
required: false,
},
{
name: "audience",
label: "目标读者",
type: "select",
required: false,
options: [
{ label: "普通大众", value: "general" },
{ label: "专业人士", value: "professional" },
{ label: "学生群体", value: "student" },
{ label: "技术开发者", value: "developer" },
],
},
{
name: "wordCount",
label: "字数要求",
type: "select",
required: false,
options: [
{ label: "1000字左右", value: "1000" },
{ label: "2000字左右", value: "2000" },
{ label: "3000字左右", value: "3000" },
{ label: "5000字以上", value: "5000" },
],
},
],
submitLabel: "开始生成",
},
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "write",
type: "write",
title: "生成初稿",
description: "AI 生成完整初稿",
aiTask: { taskType: "write", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "迭代修改",
description: "根据反馈修改完善",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
}
// 基础步骤定义(引导模式和其他模式)
const baseSteps: StepDefinition[] = [
{
id: "clarify",
type: "clarify",
title: "明确需求",
description: "确认创作主题、目标读者和风格",
form: {
fields: [
{ name: "topic", label: "内容主题", type: "text", required: true },
{
name: "audience",
label: "目标读者",
type: "select",
required: false,
options: [
{ label: "普通大众", value: "general" },
{ label: "专业人士", value: "professional" },
{ label: "学生群体", value: "student" },
{ label: "技术开发者", value: "developer" },
],
},
{
name: "style",
label: "内容风格",
type: "radio",
required: false,
options: [
{ label: "专业严谨", value: "professional" },
{ label: "轻松活泼", value: "casual" },
{ label: "深度分析", value: "analytical" },
{ label: "故事叙述", value: "narrative" },
],
},
],
submitLabel: "确认并继续",
skipLabel: "跳过",
},
behavior: { skippable: false, redoable: true, autoAdvance: true },
},
{
id: "research",
type: "research",
title: "调研收集",
description: "AI 搜索相关资料,你可以补充真实经历",
aiTask: { taskType: "research", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
{
id: "outline",
type: "outline",
title: "生成大纲",
description: "AI 生成内容大纲,你可以调整顺序",
aiTask: { taskType: "outline", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "write",
type: "write",
title: "撰写内容",
description: "根据模式不同,AI 和你协作完成内容",
aiTask: { taskType: "write", streaming: true },
behavior: { skippable: false, redoable: true, autoAdvance: false },
},
{
id: "polish",
type: "polish",
title: "润色优化",
description: "AI 检查并建议优化",
aiTask: { taskType: "polish", streaming: true },
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
{
id: "adapt",
type: "adapt",
title: "适配发布",
description: "选择目标平台,AI 自动适配格式",
form: {
fields: [
{
name: "platform",
label: "目标平台",
type: "checkbox",
required: true,
options: [
{ label: "微信公众号", value: "wechat" },
{ label: "小红书", value: "xiaohongshu" },
{ label: "知乎", value: "zhihu" },
{ label: "通用 Markdown", value: "markdown" },
],
},
],
submitLabel: "生成适配版本",
},
behavior: { skippable: true, redoable: true, autoAdvance: false },
},
];
return baseSteps;
}
/**
+10 -14
View File
@@ -7,31 +7,27 @@
import type React from "react";
/**
* 主题类型
* 主题类型(与 ProjectType 中的 UserType 保持一致)
* - general: 通用对话
* - knowledge: 知识探索
* - planning: 计划规划
* - social-media: 社媒内容
* - poster: 图文海报
* - music: 歌词曲谱
* - knowledge: 知识探索
* - planning: 计划规划
* - document: 办公文档
* - paper: 学术论文
* - novel: 小说创作
* - script: 短剧脚本
* - music: 音乐创作
* - video: 短视频
* - novel: 小说创作
*/
export type ThemeType =
| "general"
| "knowledge"
| "planning"
| "social-media"
| "poster"
| "document"
| "paper"
| "novel"
| "script"
| "music"
| "video";
| "knowledge"
| "planning"
| "document"
| "video"
| "novel";
/**
* 创作模式类型
@@ -0,0 +1,191 @@
/**
* 项目提示词生成工具
*
* 根据项目 Memory 生成系统提示词
*/
import type {
ProjectMemory,
Character,
WorldBuilding,
StyleGuide,
OutlineNode,
} from "@/lib/api/memory";
/**
* 生成角色提示词
*/
function generateCharactersPrompt(characters: Character[]): string {
if (characters.length === 0) return "";
let prompt = "### 角色设定\n\n";
// 主要角色
const mainCharacters = characters.filter((c) => c.is_main);
const sideCharacters = characters.filter((c) => !c.is_main);
if (mainCharacters.length > 0) {
prompt += "**主要角色:**\n";
mainCharacters.forEach((char) => {
prompt += `- **${char.name}**`;
if (char.aliases.length > 0) {
prompt += `(${char.aliases.join("、")})`;
}
prompt += "\n";
if (char.description) {
prompt += ` - 简介:${char.description}\n`;
}
if (char.personality) {
prompt += ` - 性格:${char.personality}\n`;
}
if (char.background) {
prompt += ` - 背景:${char.background}\n`;
}
if (char.appearance) {
prompt += ` - 外貌:${char.appearance}\n`;
}
});
prompt += "\n";
}
if (sideCharacters.length > 0) {
prompt += "**次要角色:**\n";
sideCharacters.forEach((char) => {
prompt += `- **${char.name}**`;
if (char.description) {
prompt += `:${char.description}`;
}
prompt += "\n";
});
prompt += "\n";
}
return prompt;
}
/**
* 生成世界观提示词
*/
function generateWorldBuildingPrompt(worldBuilding: WorldBuilding): string {
let prompt = "### 世界观设定\n\n";
if (worldBuilding.description) {
prompt += `${worldBuilding.description}\n\n`;
}
if (worldBuilding.era) {
prompt += `**时代背景:** ${worldBuilding.era}\n\n`;
}
if (worldBuilding.locations) {
prompt += `**主要地点:** ${worldBuilding.locations}\n\n`;
}
if (worldBuilding.rules) {
prompt += `**世界规则:** ${worldBuilding.rules}\n\n`;
}
return prompt;
}
/**
* 生成风格指南提示词
*/
function generateStyleGuidePrompt(styleGuide: StyleGuide): string {
let prompt = "### 写作风格\n\n";
if (styleGuide.style) {
prompt += `**风格:** ${styleGuide.style}\n\n`;
}
if (styleGuide.tone) {
prompt += `**语调:** ${styleGuide.tone}\n\n`;
}
if (styleGuide.forbidden_words.length > 0) {
prompt += `**禁用词汇:** ${styleGuide.forbidden_words.join("、")}\n\n`;
}
if (styleGuide.preferred_words.length > 0) {
prompt += `**推荐词汇:** ${styleGuide.preferred_words.join("、")}\n\n`;
}
if (styleGuide.examples) {
prompt += `**示例:**\n${styleGuide.examples}\n\n`;
}
return prompt;
}
/**
* 生成大纲提示词
*/
function generateOutlinePrompt(outline: OutlineNode[]): string {
if (outline.length === 0) return "";
let prompt = "### 故事大纲\n\n";
// 按 order 排序并构建层级结构
const sortedOutline = [...outline].sort((a, b) => a.order - b.order);
const rootNodes = sortedOutline.filter((n) => !n.parent_id);
const renderNode = (node: OutlineNode, level: number = 0): string => {
const indent = " ".repeat(level);
let result = `${indent}- **${node.title}**`;
if (node.content) {
result += `:${node.content}`;
}
result += "\n";
// 查找子节点
const children = sortedOutline.filter((n) => n.parent_id === node.id);
children.forEach((child) => {
result += renderNode(child, level + 1);
});
return result;
};
rootNodes.forEach((node) => {
prompt += renderNode(node);
});
return prompt + "\n";
}
/**
* 生成项目 Memory 提示词
*/
export function generateProjectMemoryPrompt(memory: ProjectMemory): string {
let prompt = "## 项目背景\n\n";
// 角色
if (memory.characters.length > 0) {
prompt += generateCharactersPrompt(memory.characters);
}
// 世界观
if (memory.world_building?.description) {
prompt += generateWorldBuildingPrompt(memory.world_building);
}
// 风格指南
if (memory.style_guide?.style) {
prompt += generateStyleGuidePrompt(memory.style_guide);
}
// 大纲
if (memory.outline.length > 0) {
prompt += generateOutlinePrompt(memory.outline);
}
return prompt;
}
/**
* 判断主题是否为内容创作主题
* 统一后,除了 general 以外都是内容创作主题
*/
export function isContentCreationTheme(theme: string): boolean {
return theme !== "general";
}
@@ -7,20 +7,18 @@
import type { ThemeType, CreationMode } from "../types";
/**
* 主题名称映射
* 主题名称映射(与 ProjectType 统一)
*/
const THEME_NAMES: Record<ThemeType, string> = {
general: "通用对话",
knowledge: "知识探索",
planning: "计划规划",
"social-media": "社媒内容",
poster: "图文海报",
music: "歌词曲谱",
knowledge: "知识探索",
planning: "计划规划",
document: "办公文档",
paper: "学术论文",
novel: "小说创作",
script: "短剧脚本",
music: "音乐创作",
video: "短视频",
novel: "小说创作",
};
/**
@@ -28,17 +26,6 @@ const THEME_NAMES: Record<ThemeType, string> = {
*/
const THEME_GUIDANCE: Record<ThemeType, string> = {
general: "",
knowledge: `
【知识探索特点】
- 深入浅出地解释概念,使用类比和例子
- 提供可靠的信息来源,标注不确定的内容
- 鼓励用户提问,引导深度思考`,
planning: `
【计划规划特点】
- 制定清晰的目标和里程碑
- 考虑时间和资源约束
- 提供可执行的行动步骤`,
"social-media": `
【社媒内容特点】
@@ -51,47 +38,51 @@ const THEME_GUIDANCE: Record<ThemeType, string> = {
- 文案简洁有力,突出核心卖点
- 考虑视觉层次,主次分明`,
document: `
【办公文档特点】
- 结构清晰,逻辑严谨
- 使用专业术语但保持可读性`,
paper: `
【学术论文特点】
- 严格遵循学术规范和引用格式
- 论证逻辑严密,数据支撑充分
- 使用专业术语,保持客观中立`,
novel: `
【小说创作特点】
- 注重人物塑造和情节发展
- 场景描写生动,对话自然
- 保持叙事节奏和悬念设置`,
script: `
【短剧脚本特点】
- 场景描述简洁明了
- 对白自然,符合人物性格
- 注重镜头语言和节奏把控`,
video: `
【短视频特点】
- 开头3秒抓注意力
- 1分钟≈150-180字`,
music: `
【音乐创作特点】
- 歌词创作:注重押韵、意象、情感表达
- 段落结构:主歌、副歌、桥段等标准结构
- 平台适配:支持 Suno、Tunee 等 AI 音乐平台格式
- 三种创作模式:教练模式(引导创作)、快速模式(AI生成)、混合模式(协作创作)`,
knowledge: `
【知识探索特点】
- 深入浅出地解释概念,使用类比和例子
- 提供可靠的信息来源,标注不确定的内容
- 鼓励用户提问,引导深度思考`,
planning: `
【计划规划特点】
- 制定清晰的目标和里程碑
- 考虑时间和资源约束
- 提供可执行的行动步骤`,
document: `
【办公文档特点】
- 结构清晰,逻辑严谨
- 使用专业术语但保持可读性`,
video: `
【短视频特点】
- 开头3秒抓注意力
- 1分钟≈150-180字
- 场景描述简洁明了
- 对白自然,符合人物性格
- 注重镜头语言和节奏把控`,
novel: `
【小说创作特点】
- 注重人物塑造和情节发展
- 场景描写生动,对话自然
- 保持叙事节奏和悬念设置`,
};
/**
* 生成文件写入格式说明
* 根据主题类型返回对应的文件体系
*/
function getFileWritingInstructions(): string {
return `
function getFileWritingInstructions(theme?: ThemeType): string {
const baseInstructions = `
## 文件写入格式
当需要输出文档内容时,使用以下标签格式:
@@ -104,7 +95,92 @@ function getFileWritingInstructions(): string {
- 这是标签格式,不是工具调用!直接写在回复文本中
- <write_file> 标签内的内容会实时流式显示在右侧画布
- 写入完成后,在对话框中简短说明即可
`;
// 根据主题类型返回对应的文件体系
let fileSystem = "";
switch (theme) {
case "social-media":
fileSystem = `
## 工作流文件体系 ⭐ 核心
**每个步骤生成独立文件,绝不覆盖!**
| 步骤 | 文件名 | 内容说明 |
|------|--------|----------|
| 1. 明确需求 | brief.md | 用户需求摘要、目标平台、受众定位 |
| 2. 创作内容 | draft.md | 社媒内容初稿 |
| 3. 润色优化 | article.md | 优化后的内容(引导模式) |
| 4. 平台适配 | adapted.md | 适配不同平台格式(引导模式) |
`;
break;
case "video":
fileSystem = `
## 工作流文件体系 ⭐ 核心
**每个步骤生成独立文件,绝不覆盖!**
| 步骤 | 文件名 | 内容说明 |
|------|--------|----------|
| 1. 明确需求 | brief.md | 视频主题、时长、目标受众 |
| 2. 剧情大纲 | outline.md | 视频整体结构和节奏规划 |
| 3. 分镜设计 | storyboard.md | 关键画面和镜头设计 |
| 4. 撰写剧本 | script.md | 完整视频脚本 |
| 5. 润色优化 | script-final.md | 优化后的最终脚本 |
`;
break;
case "novel":
fileSystem = `
## 工作流文件体系 ⭐ 核心
**每个步骤生成独立文件,绝不覆盖!**
| 步骤 | 文件名 | 内容说明 |
|------|--------|----------|
| 1. 明确需求 | brief.md | 故事主题、类型、目标读者 |
| 2. 章节大纲 | outline.md | 故事结构和章节规划 |
| 3. 角色设定 | characters.md | 主要角色和背景设定 |
| 4. 撰写内容 | chapter.md | 小说章节内容 |
| 5. 润色优化 | chapter-final.md | 优化后的章节内容 |
`;
break;
case "document":
fileSystem = `
## 工作流文件体系 ⭐ 核心
**每个步骤生成独立文件,绝不覆盖!**
| 步骤 | 文件名 | 内容说明 |
|------|--------|----------|
| 1. 明确需求 | brief.md | 文档主题、类型、目标读者 |
| 2. 文档大纲 | outline.md | 文档结构和章节规划 |
| 3. 撰写内容 | draft.md | 文档初稿 |
| 4. 润色优化 | article.md | 优化后的最终文档 |
`;
break;
case "poster":
fileSystem = `
## 工作流文件体系 ⭐ 核心
**每个步骤生成独立文件,绝不覆盖!**
| 步骤 | 文件名 | 内容说明 |
|------|--------|----------|
| 1. 需求分析 | brief.md | 海报目的、受众、使用场景 |
| 2. 文案策划 | copywriting.md | 海报标题和文案内容 |
| 3. 布局设计 | layout.md | 视觉层次和元素布局规划 |
| 4. 视觉设计 | design.md | 完整海报设计方案 |
`;
break;
default:
// 通用文件体系(用于未指定主题的情况)
fileSystem = `
## 工作流文件体系 ⭐ 核心
**每个步骤生成独立文件,绝不覆盖!**
@@ -118,6 +194,10 @@ function getFileWritingInstructions(): string {
| 5. 初稿 | draft.md | 第一版完整文章 |
| 6. 终稿 | article.md | 润色后的最终文章 |
`;
break;
}
return baseInstructions + fileSystem;
}
/**
@@ -155,6 +235,7 @@ function getFormInstructions(): string {
function generateGuidedModePrompt(
themeName: string,
themeGuidance: string,
theme?: ThemeType,
): string {
return `# 🛑 强制规则 - 必须遵守
@@ -196,7 +277,7 @@ function generateGuidedModePrompt(
- ❌ "润色"或"改写"用户的文字
- ❌ 说"我来帮你写"、"你可以这样写"
${getFileWritingInstructions()}
${getFileWritingInstructions(theme)}
${getFormInstructions()}
@@ -504,6 +585,7 @@ ${themeGuidance}
function generateFastModePrompt(
themeName: string,
themeGuidance: string,
theme?: ThemeType,
): string {
return `# 🛑 强制规则 - 必须遵守
@@ -529,7 +611,7 @@ function generateFastModePrompt(
**但是**:你必须先收集需求,不能直接生成内容!
${getFileWritingInstructions()}
${getFileWritingInstructions(theme)}
${getFormInstructions()}
@@ -666,6 +748,7 @@ ${themeGuidance}
function generateHybridModePrompt(
themeName: string,
themeGuidance: string,
theme?: ThemeType,
): string {
return `# 🛑 强制规则 - 必须遵守
@@ -691,7 +774,7 @@ function generateHybridModePrompt(
- **AI 负责(40%)**:文章框架、过渡段落、数据总结、背景介绍
- **用户负责(60%)**:核心观点、个人经验、关键案例、独特洞察
${getFileWritingInstructions()}
${getFileWritingInstructions(theme)}
${getFormInstructions()}
@@ -832,6 +915,7 @@ ${themeGuidance}
function generateFrameworkModePrompt(
themeName: string,
themeGuidance: string,
theme?: ThemeType,
): string {
return `# 🛑 强制规则 - 必须遵守
@@ -860,7 +944,7 @@ function generateFrameworkModePrompt(
- 项目立项报告、开题报告、标书、专利
- 有固定模板的重复性文档
${getFileWritingInstructions()}
${getFileWritingInstructions(theme)}
${getFormInstructions()}
@@ -1641,15 +1725,15 @@ ${themeGuidance}
// 根据创作模式生成不同的提示词
switch (mode) {
case "guided":
return generateGuidedModePrompt(themeName, themeGuidance);
return generateGuidedModePrompt(themeName, themeGuidance, theme);
case "fast":
return generateFastModePrompt(themeName, themeGuidance);
return generateFastModePrompt(themeName, themeGuidance, theme);
case "hybrid":
return generateHybridModePrompt(themeName, themeGuidance);
return generateHybridModePrompt(themeName, themeGuidance, theme);
case "framework":
return generateFrameworkModePrompt(themeName, themeGuidance);
return generateFrameworkModePrompt(themeName, themeGuidance, theme);
default:
return generateGuidedModePrompt(themeName, themeGuidance);
return generateGuidedModePrompt(themeName, themeGuidance, theme);
}
}
@@ -0,0 +1,291 @@
/**
* 内容编辑页面
*
* 集成 TipTap 编辑器和记忆侧边栏
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import {
ArrowLeft,
Save,
RefreshCw,
PanelRightClose,
PanelRight,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
ContentListItem,
ContentDetail,
Project,
getContent,
updateContent,
formatRelativeTime,
} from "@/lib/api/project";
import { EditorToolbar } from "./editor/EditorToolbar";
import { MemorySidebar } from "./MemorySidebar";
import { toast } from "sonner";
interface ContentEditorPageProps {
project: Project;
content: ContentListItem;
onBack: () => void;
}
type SaveStatus = "saved" | "saving" | "unsaved";
export function ContentEditorPage({
project,
content,
onBack,
}: ContentEditorPageProps) {
const [contentDetail, setContentDetail] = useState<ContentDetail | null>(
null,
);
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState(content.title);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
const [showSidebar, setShowSidebar] = useState(true);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// TipTap 编辑器
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Placeholder.configure({
placeholder: "开始写作...",
emptyEditorClass: "is-editor-empty",
}),
],
content: "",
onUpdate: () => {
setSaveStatus("unsaved");
scheduleAutoSave();
},
editorProps: {
attributes: {
class: cn(
"prose prose-sm sm:prose-base max-w-none focus:outline-none min-h-[calc(100vh-200px)]",
"prose-headings:font-bold prose-headings:text-foreground",
"prose-p:text-foreground prose-p:leading-relaxed",
"prose-strong:text-foreground prose-strong:font-semibold",
"prose-em:text-foreground",
"prose-ul:text-foreground prose-ol:text-foreground",
"prose-li:text-foreground",
"prose-blockquote:text-muted-foreground prose-blockquote:border-l-primary",
"prose-code:text-foreground prose-code:bg-muted prose-code:px-1 prose-code:rounded",
"prose-pre:bg-muted prose-pre:text-foreground",
),
},
},
});
// 加载内容详情
const loadContent = useCallback(async () => {
setLoading(true);
try {
const detail = await getContent(content.id);
if (detail) {
setContentDetail(detail);
setTitle(detail.title);
editor?.commands.setContent(detail.body || "");
}
} catch (error) {
console.error("加载内容失败:", error);
toast.error("加载内容失败");
} finally {
setLoading(false);
}
}, [content.id, editor]);
useEffect(() => {
loadContent();
}, [loadContent]);
// 保存内容的 ref(用于避免循环依赖)
const handleSaveRef = useRef<() => Promise<void>>();
// 自动保存调度
const scheduleAutoSave = useCallback(() => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
handleSaveRef.current?.();
}, 3000);
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, []);
// 保存内容
const handleSave = async () => {
if (!editor || saveStatus === "saving") return;
setSaveStatus("saving");
try {
const body = editor.getHTML();
await updateContent(content.id, {
title,
body,
});
setSaveStatus("saved");
} catch (error) {
console.error("保存失败:", error);
toast.error("保存失败");
setSaveStatus("unsaved");
}
};
// 更新 ref
handleSaveRef.current = handleSave;
// 标题变化时标记为未保存
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle);
setSaveStatus("unsaved");
scheduleAutoSave();
};
// 手动保存
const handleManualSave = () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
handleSave();
};
// 返回时检查是否有未保存的更改
const handleBack = () => {
if (saveStatus === "unsaved") {
if (confirm("有未保存的更改,确定要离开吗?")) {
onBack();
}
} else {
onBack();
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 头部 */}
<div className="flex items-center gap-4 p-4 border-b">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
{/* 标题输入 */}
<Input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
className="flex-1 text-lg font-medium border-none shadow-none focus-visible:ring-0"
placeholder="输入标题..."
/>
{/* 保存状态 */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{saveStatus === "saving" && (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
<span>保存中...</span>
</>
)}
{saveStatus === "saved" && (
<>
<Check className="h-4 w-4 text-green-500" />
<span>已保存</span>
</>
)}
{saveStatus === "unsaved" && <span>未保存</span>}
</div>
{/* 操作按钮 */}
<Button
variant="outline"
size="sm"
onClick={handleManualSave}
disabled={saveStatus === "saving" || saveStatus === "saved"}
>
<Save className="h-4 w-4 mr-2" />
保存
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSidebar(!showSidebar)}
title={showSidebar ? "隐藏侧边栏" : "显示侧边栏"}
>
{showSidebar ? (
<PanelRightClose className="h-5 w-5" />
) : (
<PanelRight className="h-5 w-5" />
)}
</Button>
</div>
{/* 主体 */}
<div className="flex-1 flex overflow-hidden">
{/* 编辑器区域 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 工具栏 */}
<EditorToolbar editor={editor} />
{/* 编辑器 */}
<div className="flex-1 overflow-auto">
<div
className={cn(
"max-w-4xl mx-auto p-6",
"[&_.is-editor-empty:first-child::before]:text-muted-foreground",
"[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
"[&_.is-editor-empty:first-child::before]:float-left",
"[&_.is-editor-empty:first-child::before]:h-0",
"[&_.is-editor-empty:first-child::before]:pointer-events-none",
)}
>
<EditorContent editor={editor} />
</div>
</div>
{/* 底部状态栏 */}
<div className="flex items-center justify-between px-4 py-2 border-t text-xs text-muted-foreground">
<span>
字数: {editor?.storage.characterCount?.characters?.() || 0}
</span>
<span>
最后更新: {formatRelativeTime(contentDetail?.updated_at || 0)}
</span>
</div>
</div>
{/* 记忆侧边栏 */}
{showSidebar && (
<MemorySidebar projectId={project.id} className="w-72" />
)}
</div>
</div>
);
}
+549
View File
@@ -0,0 +1,549 @@
/**
* 内容列表页面
*
* 显示项目下的所有内容,支持表格和卡片视图
*/
import { useState, useEffect, useMemo, useCallback } from "react";
import {
ArrowLeft,
Plus,
Search,
List,
RefreshCw,
MoreHorizontal,
Edit2,
Trash2,
CheckCircle2,
FileText,
Users,
Globe,
FileEdit,
Settings,
Palette,
Film,
MapPin,
LayoutGrid,
MessageSquare,
Image,
Copy,
LucideIcon,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Project,
ContentListItem,
ContentStatus,
listContents,
createContent,
updateContent,
deleteContent,
getContentStats,
getProjectTypeLabel,
getContentTypeLabel,
getContentStatusLabel,
getDefaultContentTypeForProject,
formatWordCount,
formatRelativeTime,
} from "@/lib/api/project";
import { toast } from "sonner";
import {
CharacterPanel,
WorldBuildingPanel,
StyleGuidePanel,
OutlinePanel,
} from "./memory";
import { ProjectType } from "@/lib/api/project";
// Tab 配置类型
interface TabConfig {
value: string;
label: string;
icon: LucideIcon;
}
// 不同项目类型的 Tab 配置
const PROJECT_TAB_CONFIG: Record<ProjectType, TabConfig[]> = {
// 系统类型
persistent: [{ value: "contents", label: "内容", icon: FileEdit }],
temporary: [{ value: "contents", label: "内容", icon: FileEdit }],
// 用户类型
general: [
{ value: "contents", label: "内容", icon: FileEdit },
{ value: "characters", label: "角色", icon: Users },
{ value: "world", label: "世界观", icon: Globe },
{ value: "style", label: "风格", icon: Palette },
{ value: "outline", label: "大纲", icon: List },
],
"social-media": [
{ value: "contents", label: "帖子", icon: MessageSquare },
{ value: "assets", label: "素材", icon: Image },
{ value: "style", label: "风格", icon: Palette },
],
poster: [
{ value: "contents", label: "设计", icon: Image },
{ value: "assets", label: "素材", icon: Image },
],
music: [
{ value: "contents", label: "歌曲", icon: FileEdit },
{ value: "style", label: "风格", icon: Palette },
],
knowledge: [{ value: "contents", label: "笔记", icon: FileText }],
planning: [
{ value: "contents", label: "计划", icon: FileEdit },
{ value: "outline", label: "大纲", icon: List },
],
document: [
{ value: "contents", label: "文档", icon: FileText },
{ value: "templates", label: "模板", icon: Copy },
],
video: [
{ value: "contents", label: "剧集", icon: Film },
{ value: "characters", label: "角色", icon: Users },
{ value: "scenes", label: "场景", icon: MapPin },
{ value: "storyboard", label: "分镜", icon: LayoutGrid },
{ value: "outline", label: "大纲", icon: List },
],
novel: [
{ value: "contents", label: "章节", icon: FileEdit },
{ value: "characters", label: "角色", icon: Users },
{ value: "world", label: "世界观", icon: Globe },
{ value: "style", label: "风格", icon: Palette },
{ value: "outline", label: "大纲", icon: List },
],
};
interface ContentListPageProps {
project: Project;
onBack: () => void;
onSelectContent?: (content: ContentListItem) => void;
}
type ContentFilter = "all" | "completed" | "draft" | "published";
type ContentTab =
| "contents"
| "characters"
| "world"
| "style"
| "outline"
| "scenes"
| "storyboard"
| "assets"
| "templates";
export function ContentListPage({
project,
onBack,
onSelectContent,
}: ContentListPageProps) {
const [contents, setContents] = useState<ContentListItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [currentFilter, setCurrentFilter] = useState<ContentFilter>("all");
const [currentTab, setCurrentTab] = useState<ContentTab>("contents");
const [_viewMode, _setViewMode] = useState<"table" | "grid">("table");
const [stats, setStats] = useState<{
count: number;
words: number;
completed: number;
} | null>(null);
// 加载内容列表
const loadContents = useCallback(async () => {
setLoading(true);
try {
const [contentList, [count, words, completed]] = await Promise.all([
listContents(project.id),
getContentStats(project.id),
]);
setContents(contentList);
setStats({ count, words, completed });
} catch (error) {
console.error("加载内容失败:", error);
toast.error("加载内容失败");
} finally {
setLoading(false);
}
}, [project.id]);
useEffect(() => {
loadContents();
}, [loadContents]);
// 过滤内容
const filteredContents = useMemo(() => {
let result = contents;
// 按状态过滤
if (currentFilter !== "all") {
result = result.filter((c) => c.status === currentFilter);
}
// 搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((c) => c.title.toLowerCase().includes(query));
}
return result;
}, [contents, currentFilter, searchQuery]);
// 创建新内容
const handleCreateContent = async () => {
try {
const defaultType = getDefaultContentTypeForProject(
project.workspaceType,
);
const newContent = await createContent({
project_id: project.id,
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
});
toast.success("创建成功");
loadContents();
onSelectContent?.(newContent);
} catch (error) {
console.error("创建内容失败:", error);
toast.error("创建失败");
}
};
// 获取默认内容类型
// 更新内容状态
const handleUpdateStatus = async (
content: ContentListItem,
status: ContentStatus,
) => {
try {
await updateContent(content.id, { status });
toast.success("状态已更新");
loadContents();
} catch (error) {
console.error("更新状态失败:", error);
toast.error("更新失败");
}
};
// 删除内容
const handleDeleteContent = async (content: ContentListItem) => {
if (!confirm(`确定要删除 "${content.title}" 吗?`)) {
return;
}
try {
await deleteContent(content.id);
toast.success("已删除");
loadContents();
} catch (error) {
console.error("删除失败:", error);
toast.error("删除失败");
}
};
// 计算进度
const progress = stats
? stats.count > 0
? (stats.completed / stats.count) * 100
: 0
: 0;
// 获取状态图标
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "published":
return <CheckCircle2 className="h-4 w-4 text-blue-500" />;
default:
return <FileText className="h-4 w-4 text-muted-foreground" />;
}
};
return (
<div className="flex flex-col h-full">
{/* 头部 */}
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-xl font-bold flex items-center gap-2">
<span>{project.icon || "📁"}</span>
<span>{project.name}</span>
</h1>
<p className="text-sm text-muted-foreground">
{getProjectTypeLabel(project.workspaceType)}
</p>
</div>
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
设置
</Button>
</div>
{/* 项目信息卡片 */}
<div className="bg-card rounded-lg border p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
进度: {stats?.completed || 0}/{stats?.count || 0} (
{progress.toFixed(0)}%)
</span>
<span className="text-sm text-muted-foreground">
总字数: {formatWordCount(stats?.words || 0)}
</span>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* 标签页 */}
<Tabs
value={currentTab}
onValueChange={(v) => setCurrentTab(v as ContentTab)}
className="mb-4"
>
<TabsList>
{(
PROJECT_TAB_CONFIG[project.workspaceType] ||
PROJECT_TAB_CONFIG.general
).map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* 内容列表区域 */}
{currentTab === "contents" && (
<>
{/* 工具栏 */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
{(["all", "completed", "draft"] as ContentFilter[]).map(
(filter) => (
<Button
key={filter}
variant={currentFilter === filter ? "secondary" : "ghost"}
size="sm"
onClick={() => setCurrentFilter(filter)}
>
{filter === "all"
? "全部"
: filter === "completed"
? "已完成"
: "草稿"}
</Button>
),
)}
</div>
<div className="flex-1" />
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<Button onClick={handleCreateContent}>
<Plus className="h-4 w-4 mr-2" />
新建
</Button>
</div>
{/* 内容表格 */}
<div className="flex-1 overflow-auto border rounded-lg">
{loading ? (
<div className="flex items-center justify-center h-40">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredContents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
<p className="mb-4">还没有内容</p>
<Button onClick={handleCreateContent}>创建第一个内容</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>标题</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-24">字数</TableHead>
<TableHead className="w-32">更新时间</TableHead>
<TableHead className="w-20">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredContents.map((content) => (
<TableRow
key={content.id}
className="cursor-pointer hover:bg-accent/50"
onClick={() => onSelectContent?.(content)}
>
<TableCell className="font-mono text-muted-foreground">
{content.order + 1}
</TableCell>
<TableCell className="font-medium">
{content.title}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStatusIcon(content.status)}
<span className="text-sm">
{getContentStatusLabel(
content.status as ContentStatus,
)}
</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{formatWordCount(content.word_count)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatRelativeTime(content.updated_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onSelectContent?.(content);
}}
>
<Edit2 className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
{content.status !== "completed" && (
<DropdownMenuItem
onClick={() => {
handleUpdateStatus(content, "completed");
}}
>
<CheckCircle2 className="h-4 w-4 mr-2" />
标记完成
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
handleDeleteContent(content);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</>
)}
{/* 角色标签页 */}
{currentTab === "characters" && (
<div className="flex-1 overflow-hidden">
<CharacterPanel projectId={project.id} />
</div>
)}
{/* 世界观标签页 */}
{currentTab === "world" && (
<div className="flex-1 overflow-hidden">
<WorldBuildingPanel projectId={project.id} />
</div>
)}
{/* 风格指南标签页 */}
{currentTab === "style" && (
<div className="flex-1 overflow-hidden">
<StyleGuidePanel projectId={project.id} />
</div>
)}
{/* 大纲标签页 */}
{currentTab === "outline" && (
<div className="flex-1 overflow-hidden">
<OutlinePanel projectId={project.id} />
</div>
)}
{/* 场景标签页(短剧) */}
{currentTab === "scenes" && (
<div className="flex-1 overflow-hidden flex items-center justify-center text-muted-foreground">
<div className="text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>场景管理功能开发中...</p>
</div>
</div>
)}
{/* 分镜标签页(短剧) */}
{currentTab === "storyboard" && (
<div className="flex-1 overflow-hidden flex items-center justify-center text-muted-foreground">
<div className="text-center">
<LayoutGrid className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>分镜管理功能开发中...</p>
</div>
</div>
)}
{/* 素材标签页(社媒) */}
{currentTab === "assets" && (
<div className="flex-1 overflow-hidden flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Image className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>素材管理功能开发中...</p>
</div>
</div>
)}
{/* 模板标签页(文档) */}
{currentTab === "templates" && (
<div className="flex-1 overflow-hidden flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Copy className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>模板管理功能开发中...</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,145 @@
/**
* 创建项目对话框
*
* 用于创建新项目
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
ProjectType,
USER_PROJECT_TYPES,
getProjectTypeLabel,
getProjectTypeIcon,
} from "@/lib/api/project";
interface CreateProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (name: string, type: ProjectType) => Promise<void>;
defaultType?: ProjectType;
defaultName?: string;
}
export function CreateProjectDialog({
open,
onOpenChange,
onSubmit,
defaultType,
defaultName,
}: CreateProjectDialogProps) {
const [name, setName] = useState("");
const [type, setType] = useState<ProjectType>(defaultType || "general");
const [isSubmitting, setIsSubmitting] = useState(false);
// 当对话框打开且 defaultType 变化时,更新类型选择
useEffect(() => {
if (open && defaultType) {
setType(defaultType);
}
}, [open, defaultType]);
// 当对话框打开且 defaultName 变化时,更新项目名称
useEffect(() => {
if (open && defaultName) {
setName(defaultName);
}
}, [open, defaultName]);
const handleSubmit = async () => {
if (!name.trim()) return;
setIsSubmitting(true);
try {
await onSubmit(name.trim(), type);
setName("");
setType(defaultType || "general");
onOpenChange(false);
} catch (error) {
// 如果是用户取消选择目录,不显示错误
if (error instanceof Error && error.message === "用户取消选择目录") {
// 用户取消,不做任何处理
} else {
console.error("创建项目失败:", error);
}
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>新建项目</DialogTitle>
<DialogDescription>
创建一个新的内容创作项目,选择项目类型以获得最佳体验。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4">
{/* 项目名称 */}
<div className="grid gap-2">
<Label htmlFor="name">项目名称</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入项目名称..."
autoFocus
/>
</div>
{/* 项目类型 */}
<div className="grid gap-3">
<Label>项目类型</Label>
<div className="grid grid-cols-3 gap-3">
{USER_PROJECT_TYPES.map((t) => (
<button
key={t}
type="button"
className={cn(
"flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all",
"hover:border-primary/50 hover:bg-accent/50",
type === t
? "border-primary bg-primary/5 shadow-sm"
: "border-border",
)}
onClick={() => setType(t)}
>
<span className="text-2xl">{getProjectTypeIcon(t)}</span>
<span className="text-xs font-medium">
{getProjectTypeLabel(t)}
</span>
</button>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
onClick={handleSubmit}
disabled={!name.trim() || isSubmitting}
>
{isSubmitting ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
/**
* 删除项目确认对话框
*
* 提供危险操作警告,支持选择是否同时删除目录
*/
import { useState } from "react";
import { AlertTriangle, Trash2, FolderX } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Project } from "@/lib/api/project";
interface DeleteProjectDialogProps {
project: Project | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (deleteDirectory: boolean) => Promise<void>;
}
export function DeleteProjectDialog({
project,
open,
onOpenChange,
onConfirm,
}: DeleteProjectDialogProps) {
const [deleteDirectory, setDeleteDirectory] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleConfirm = async () => {
setIsDeleting(true);
try {
await onConfirm(deleteDirectory);
onOpenChange(false);
setDeleteDirectory(false);
} finally {
setIsDeleting(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!isDeleting) {
onOpenChange(newOpen);
if (!newOpen) {
setDeleteDirectory(false);
}
}
};
if (!project) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
删除项目
</DialogTitle>
<DialogDescription>
确定要删除项目 <strong>"{project.name}"</strong> 吗?
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* 警告信息 */}
<div className="rounded-lg bg-destructive/10 border border-destructive/20 p-3 text-sm">
<p className="text-destructive font-medium mb-1">
⚠️ 此操作不可恢复
</p>
<p className="text-muted-foreground">
删除后,项目的所有内容记录将从数据库中移除。
</p>
</div>
{/* 删除目录选项 */}
<div className="flex items-start space-x-3 rounded-lg border p-3">
<Checkbox
id="delete-directory"
checked={deleteDirectory}
onCheckedChange={(checked) =>
setDeleteDirectory(checked === true)
}
/>
<div className="space-y-1">
<Label
htmlFor="delete-directory"
className="flex items-center gap-2 cursor-pointer font-medium"
>
<FolderX className="h-4 w-4 text-destructive" />
同时删除项目目录
</Label>
<p className="text-xs text-muted-foreground">
将删除目录:
<code className="bg-muted px-1 rounded">
{project.rootPath}
</code>
</p>
{deleteDirectory && (
<p className="text-xs text-destructive font-medium">
⚠️ 目录中的所有文件将被永久删除!
</p>
)}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isDeleting}
>
取消
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isDeleting}
>
{isDeleting ? (
"删除中..."
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
{deleteDirectory ? "删除项目和目录" : "删除项目"}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+296
View File
@@ -0,0 +1,296 @@
/**
* 记忆侧边栏
*
* 在编辑页面显示项目的角色、世界观、风格指南(只读)
*/
import React, { useState, useEffect, useCallback } from "react";
import {
RefreshCw,
Users,
Globe,
Palette,
ChevronDown,
ChevronRight,
Star,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
ProjectMemory,
Character,
WorldBuilding,
StyleGuide,
getProjectMemory,
} from "@/lib/api/memory";
interface MemorySidebarProps {
projectId: string;
className?: string;
}
export function MemorySidebar({ projectId, className }: MemorySidebarProps) {
const [memory, setMemory] = useState<ProjectMemory | null>(null);
const [loading, setLoading] = useState(true);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(["characters", "world", "style"]),
);
const loadMemory = useCallback(async () => {
setLoading(true);
try {
const data = await getProjectMemory(projectId);
setMemory(data);
} catch (error) {
console.error("加载记忆失败:", error);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadMemory();
}, [loadMemory]);
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
if (loading) {
return (
<div
className={cn(
"flex items-center justify-center h-40 border-l bg-muted/30",
className,
)}
>
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className={cn("border-l bg-muted/30 flex flex-col", className)}>
{/* 头部 */}
<div className="flex items-center justify-between p-3 border-b">
<span className="text-sm font-medium">项目记忆</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={loadMemory}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* 内容 */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-2">
{/* 角色 */}
<SidebarSection
title="角色"
icon={<Users className="h-4 w-4" />}
count={memory?.characters.length || 0}
expanded={expandedSections.has("characters")}
onToggle={() => toggleSection("characters")}
>
{memory?.characters && memory.characters.length > 0 ? (
<div className="space-y-2">
{memory.characters.map((character) => (
<CharacterItem key={character.id} character={character} />
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-2">暂无角色</p>
)}
</SidebarSection>
{/* 世界观 */}
<SidebarSection
title="世界观"
icon={<Globe className="h-4 w-4" />}
expanded={expandedSections.has("world")}
onToggle={() => toggleSection("world")}
>
{memory?.world_building ? (
<WorldBuildingItem worldBuilding={memory.world_building} />
) : (
<p className="text-xs text-muted-foreground py-2">
暂无世界观设定
</p>
)}
</SidebarSection>
{/* 风格指南 */}
<SidebarSection
title="风格指南"
icon={<Palette className="h-4 w-4" />}
expanded={expandedSections.has("style")}
onToggle={() => toggleSection("style")}
>
{memory?.style_guide ? (
<StyleGuideItem styleGuide={memory.style_guide} />
) : (
<p className="text-xs text-muted-foreground py-2">暂无风格指南</p>
)}
</SidebarSection>
</div>
</ScrollArea>
</div>
);
}
// 侧边栏分区组件
interface SidebarSectionProps {
title: string;
icon: React.ReactNode;
count?: number;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function SidebarSection({
title,
icon,
count,
expanded,
onToggle,
children,
}: SidebarSectionProps) {
return (
<Collapsible open={expanded} onOpenChange={onToggle}>
<CollapsibleTrigger className="flex items-center gap-2 w-full p-2 rounded hover:bg-accent/50 text-sm">
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{icon}
<span className="flex-1 text-left">{title}</span>
{count !== undefined && (
<span className="text-xs text-muted-foreground">{count}</span>
)}
</CollapsibleTrigger>
<CollapsibleContent className="pl-8 pr-2">{children}</CollapsibleContent>
</Collapsible>
);
}
// 角色项组件
interface CharacterItemProps {
character: Character;
}
function CharacterItem({ character }: CharacterItemProps) {
return (
<div className="p-2 rounded bg-background border text-xs">
<div className="flex items-center gap-2 mb-1">
<User className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{character.name}</span>
{character.is_main && (
<Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />
)}
</div>
{character.description && (
<p className="text-muted-foreground line-clamp-2">
{character.description}
</p>
)}
</div>
);
}
// 世界观项组件
interface WorldBuildingItemProps {
worldBuilding: WorldBuilding;
}
function WorldBuildingItem({ worldBuilding }: WorldBuildingItemProps) {
return (
<div className="p-2 rounded bg-background border text-xs space-y-2">
{worldBuilding.description && (
<div>
<span className="text-muted-foreground">描述:</span>
<p className="line-clamp-3">{worldBuilding.description}</p>
</div>
)}
{worldBuilding.era && (
<div>
<span className="text-muted-foreground">时代:</span>
<span>{worldBuilding.era}</span>
</div>
)}
{worldBuilding.locations && (
<div>
<span className="text-muted-foreground">地点:</span>
<p className="line-clamp-2">{worldBuilding.locations}</p>
</div>
)}
</div>
);
}
// 风格指南项组件
interface StyleGuideItemProps {
styleGuide: StyleGuide;
}
function StyleGuideItem({ styleGuide }: StyleGuideItemProps) {
return (
<div className="p-2 rounded bg-background border text-xs space-y-2">
{styleGuide.style && (
<div>
<span className="text-muted-foreground">风格:</span>
<p className="line-clamp-2">{styleGuide.style}</p>
</div>
)}
{styleGuide.tone && (
<div>
<span className="text-muted-foreground">语气:</span>
<span>{styleGuide.tone}</span>
</div>
)}
{styleGuide.forbidden_words.length > 0 && (
<div>
<span className="text-muted-foreground">禁用词:</span>
<span>{styleGuide.forbidden_words.slice(0, 5).join(", ")}</span>
{styleGuide.forbidden_words.length > 5 && (
<span className="text-muted-foreground">
{" "}
等 {styleGuide.forbidden_words.length} 个
</span>
)}
</div>
)}
{styleGuide.preferred_words.length > 0 && (
<div>
<span className="text-muted-foreground">偏好词:</span>
<span>{styleGuide.preferred_words.slice(0, 5).join(", ")}</span>
{styleGuide.preferred_words.length > 5 && (
<span className="text-muted-foreground">
{" "}
等 {styleGuide.preferred_words.length} 个
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
/**
* 新建项目卡片组件
*
* 显示创建新项目的入口卡片
*/
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
interface NewProjectCardProps {
onClick?: () => void;
}
export function NewProjectCard({ onClick }: NewProjectCardProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center p-4 rounded-xl border-2 border-dashed",
"bg-card/50 hover:bg-accent/30 cursor-pointer transition-all",
"hover:border-primary/40 min-h-[160px]",
)}
onClick={onClick}
>
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<span className="text-sm text-muted-foreground">新建项目</span>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
/**
* 项目卡片组件
*
* 显示单个项目的卡片视图
*/
import { Star, Archive, MoreHorizontal, Trash2, Edit2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Project,
getProjectTypeIcon,
formatWordCount,
formatRelativeTime,
} from "@/lib/api/project";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
interface ProjectCardProps {
project: Project;
onClick?: () => void;
onFavorite?: () => void;
onArchive?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
export function ProjectCard({
project,
onClick,
onFavorite,
onArchive,
onEdit,
onDelete,
}: ProjectCardProps) {
const icon = project.icon || getProjectTypeIcon(project.workspaceType);
const stats = project.stats;
return (
<div
className={cn(
"group relative flex flex-col p-4 rounded-xl border bg-card hover:bg-accent/50 cursor-pointer transition-all",
"hover:shadow-md hover:border-primary/20",
)}
onClick={onClick}
>
{/* 顶部操作区 */}
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onFavorite?.();
}}
>
<Star
className={cn(
"h-4 w-4",
project.isFavorite && "fill-yellow-400 text-yellow-400",
)}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={onArchive}>
<Archive className="h-4 w-4 mr-2" />
{project.isArchived ? "取消归档" : "归档"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 图标 */}
<div className="text-4xl mb-3">{icon}</div>
{/* 项目名称 */}
<h3 className="font-medium text-base mb-1 line-clamp-1">
{project.name}
</h3>
{/* 统计信息 */}
{stats && (
<div className="text-sm text-muted-foreground mb-2">
{stats.content_count > 0 && (
<span>
{stats.completed_count}/{stats.content_count} 完成
</span>
)}
{stats.total_words > 0 && (
<span className="ml-2">{formatWordCount(stats.total_words)}字</span>
)}
</div>
)}
{/* 标签 */}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{project.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs rounded-full bg-muted text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* 更新时间 */}
<div className="text-xs text-muted-foreground mt-auto">
更新于 {formatRelativeTime(project.updatedAt)}
</div>
{/* 收藏标记 */}
{project.isFavorite && (
<Star className="absolute top-3 left-3 h-4 w-4 fill-yellow-400 text-yellow-400" />
)}
</div>
);
}
@@ -0,0 +1,109 @@
/**
* 项目分类过滤组件
*
* 显示项目类型过滤标签
*/
import { cn } from "@/lib/utils";
import { TYPE_CONFIGS, type UserType } from "@/lib/api/project";
export type ProjectFilter = "all" | UserType | "favorites" | "archived";
interface ProjectCategoriesProps {
currentFilter: ProjectFilter;
onFilterChange: (filter: ProjectFilter) => void;
counts?: Record<ProjectFilter, number>;
}
const filterItems: { id: ProjectFilter; label: string; icon?: string }[] = [
{ id: "all", label: "全部" },
{
id: "general",
label: TYPE_CONFIGS.general.label,
icon: TYPE_CONFIGS.general.icon,
},
{
id: "social-media",
label: TYPE_CONFIGS["social-media"].label,
icon: TYPE_CONFIGS["social-media"].icon,
},
{
id: "poster",
label: TYPE_CONFIGS.poster.label,
icon: TYPE_CONFIGS.poster.icon,
},
{
id: "music",
label: TYPE_CONFIGS.music.label,
icon: TYPE_CONFIGS.music.icon,
},
{
id: "knowledge",
label: TYPE_CONFIGS.knowledge.label,
icon: TYPE_CONFIGS.knowledge.icon,
},
{
id: "planning",
label: TYPE_CONFIGS.planning.label,
icon: TYPE_CONFIGS.planning.icon,
},
{
id: "document",
label: TYPE_CONFIGS.document.label,
icon: TYPE_CONFIGS.document.icon,
},
{
id: "video",
label: TYPE_CONFIGS.video.label,
icon: TYPE_CONFIGS.video.icon,
},
{
id: "novel",
label: TYPE_CONFIGS.novel.label,
icon: TYPE_CONFIGS.novel.icon,
},
{ id: "favorites", label: "收藏", icon: "⭐" },
{ id: "archived", label: "归档", icon: "📦" },
];
export function ProjectCategories({
currentFilter,
onFilterChange,
counts,
}: ProjectCategoriesProps) {
return (
<div className="flex items-center gap-2 flex-wrap">
{filterItems.map((item) => {
const count = counts?.[item.id];
return (
<button
key={item.id}
className={cn(
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
"flex items-center gap-1.5",
currentFilter === item.id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onClick={() => onFilterChange(item.id)}
>
{item.icon && <span>{item.icon}</span>}
<span>{item.label}</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"ml-1 px-1.5 py-0.5 text-xs rounded-full",
currentFilter === item.id
? "bg-primary-foreground/20"
: "bg-background",
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
);
}
+468
View File
@@ -0,0 +1,468 @@
/**
* 项目列表页面
*
* 显示所有项目,支持卡片/列表视图切换和分类过滤
*/
import { useState, useEffect, useMemo, useCallback } from "react";
import { Search, LayoutGrid, List, RefreshCw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Project,
ProjectType,
ContentListItem,
listProjects,
createProject,
updateProject,
deleteProject,
isUserProjectType,
getContentStats,
getCreateProjectErrorMessage,
generateProjectName,
getDefaultProjectPath,
} from "@/lib/api/project";
import { ProjectCard } from "./ProjectCard";
import { NewProjectCard } from "./NewProjectCard";
import { ProjectCategories, ProjectFilter } from "./ProjectCategories";
import { CreateProjectDialog } from "./CreateProjectDialog";
import { DeleteProjectDialog } from "./DeleteProjectDialog";
import { ContentListPage } from "./ContentListPage";
import { toast } from "sonner";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Page, PageParams } from "@/types/page";
interface ProjectsPageProps {
onNavigate?: (page: Page, params?: PageParams) => void;
}
export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [currentFilter, setCurrentFilter] = useState<ProjectFilter>("all");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
// 加载项目列表
const loadProjects = useCallback(async () => {
setLoading(true);
try {
const allProjects = await listProjects();
// 只显示用户级项目类型的 workspace
const projectList = allProjects.filter((p) =>
isUserProjectType(p.workspaceType),
);
// 加载每个项目的统计信息
const projectsWithStats = await Promise.all(
projectList.map(async (project) => {
try {
const [contentCount, totalWords, completedCount] =
await getContentStats(project.id);
return {
...project,
stats: {
content_count: contentCount,
total_words: totalWords,
completed_count: completedCount,
},
};
} catch {
return project;
}
}),
);
setProjects(projectsWithStats);
} catch (error) {
console.error("加载项目失败:", error);
toast.error("加载项目失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProjects();
}, [loadProjects]);
// 过滤项目
const filteredProjects = useMemo(() => {
let result = projects;
// 按类型过滤
switch (currentFilter) {
case "general":
case "social-media":
case "poster":
case "music":
case "knowledge":
case "planning":
case "document":
case "video":
case "novel":
result = result.filter((p) => p.workspaceType === currentFilter);
break;
case "favorites":
result = result.filter((p) => p.isFavorite);
break;
case "archived":
result = result.filter((p) => p.isArchived);
break;
default:
// 默认不显示归档的项目
result = result.filter((p) => !p.isArchived);
}
// 搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.tags.some((t) => t.toLowerCase().includes(query)),
);
}
return result;
}, [projects, currentFilter, searchQuery]);
// 计算各分类数量
const filterCounts = useMemo(() => {
const nonArchived = projects.filter((p) => !p.isArchived);
return {
all: nonArchived.length,
general: projects.filter(
(p) => p.workspaceType === "general" && !p.isArchived,
).length,
"social-media": projects.filter(
(p) => p.workspaceType === "social-media" && !p.isArchived,
).length,
poster: projects.filter(
(p) => p.workspaceType === "poster" && !p.isArchived,
).length,
music: projects.filter(
(p) => p.workspaceType === "music" && !p.isArchived,
).length,
knowledge: projects.filter(
(p) => p.workspaceType === "knowledge" && !p.isArchived,
).length,
planning: projects.filter(
(p) => p.workspaceType === "planning" && !p.isArchived,
).length,
document: projects.filter(
(p) => p.workspaceType === "document" && !p.isArchived,
).length,
video: projects.filter(
(p) => p.workspaceType === "video" && !p.isArchived,
).length,
novel: projects.filter(
(p) => p.workspaceType === "novel" && !p.isArchived,
).length,
favorites: projects.filter((p) => p.isFavorite && !p.isArchived).length,
archived: projects.filter((p) => p.isArchived).length,
};
}, [projects]);
// 创建项目
const handleCreateProject = async (name: string, type: ProjectType) => {
// 选择项目目录
const selectedPath = await openDialog({
directory: true,
title: "选择项目目录",
});
if (!selectedPath) {
// 用户取消选择,抛出错误让对话框知道
throw new Error("用户取消选择目录");
}
try {
const projectPath = Array.isArray(selectedPath)
? selectedPath.length === 1
? selectedPath[0]
: null
: selectedPath;
if (!projectPath) {
throw new Error("请选择单个项目目录");
}
await createProject({
name,
rootPath: projectPath,
workspaceType: type,
});
toast.success("项目创建成功");
loadProjects();
} catch (error) {
console.error("创建项目失败:", error);
const errorMessage =
error instanceof Error
? error.message
: typeof error === "string"
? error
: error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: String(error);
const friendlyMessage = getCreateProjectErrorMessage(errorMessage);
toast.error(`创建项目失败: ${friendlyMessage}`);
throw error;
}
};
// 快速创建项目(不弹窗,直接用规则创建)
const handleQuickCreateProject = async (type: ProjectType = "general") => {
// 生成规则名称
const name = generateProjectName(type);
try {
// 使用默认项目路径
const rootPath = getDefaultProjectPath();
await createProject({
name,
rootPath,
workspaceType: type,
});
// 静默成功,不显示 toast
console.log("[ProjectsPage] 快速创建项目成功:", name);
loadProjects();
} catch (error) {
console.error("快速创建项目失败:", error);
// 静默失败,不提示用户
}
};
// 切换收藏
const handleToggleFavorite = async (project: Project) => {
try {
await updateProject(project.id, {
isFavorite: !project.isFavorite,
});
loadProjects();
} catch (error) {
console.error("更新收藏状态失败:", error);
toast.error("操作失败");
}
};
// 切换归档
const handleToggleArchive = async (project: Project) => {
try {
await updateProject(project.id, {
isArchived: !project.isArchived,
});
toast.success(project.isArchived ? "已取消归档" : "已归档");
loadProjects();
} catch (error) {
console.error("更新归档状态失败:", error);
toast.error("操作失败");
}
};
// 删除项目
const handleDeleteProject = async (project: Project) => {
setProjectToDelete(project);
setDeleteDialogOpen(true);
};
// 确认删除项目
const handleConfirmDelete = async (deleteDirectory: boolean) => {
if (!projectToDelete) return;
try {
await deleteProject(projectToDelete.id, deleteDirectory);
toast.success(deleteDirectory ? "项目和目录已删除" : "项目已删除");
loadProjects();
} catch (error) {
console.error("删除项目失败:", error);
toast.error("删除失败");
throw error;
}
};
// 点击项目
const handleProjectClick = (project: Project) => {
setSelectedProject(project);
};
// 点击内容时跳转到创作界面
const handleContentClick = (content: ContentListItem) => {
if (selectedProject && onNavigate) {
onNavigate("agent", {
projectId: selectedProject.id,
contentId: content.id,
});
}
};
// 如果选中了项目,显示内容列表页
if (selectedProject) {
return (
<ContentListPage
project={selectedProject}
onBack={() => setSelectedProject(null)}
onSelectContent={handleContentClick}
/>
);
}
return (
<div className="flex flex-col h-full">
{/* 头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">项目</h1>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={loadProjects}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* 搜索和过滤 */}
<div className="flex items-center gap-4 mb-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode("grid")}
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* 分类过滤 */}
<div className="mb-6">
<ProjectCategories
currentFilter={currentFilter}
onFilterChange={setCurrentFilter}
counts={filterCounts}
/>
</div>
{/* 项目列表 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-40">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredProjects.length === 0 && !searchQuery ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
<p className="mb-4">还没有项目</p>
<Button
onClick={() => {
// 将 ProjectFilter 转换为 ProjectType
let projectType: ProjectType = "general";
const filter = currentFilter as string;
if (
filter !== "all" &&
filter !== "favorites" &&
filter !== "archived"
) {
projectType = currentFilter as ProjectType;
}
handleQuickCreateProject(projectType);
}}
>
创建第一个项目
</Button>
</div>
) : (
<div
className={cn(
viewMode === "grid"
? "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"
: "flex flex-col gap-2",
)}
>
{/* 新建项目卡片 */}
{(currentFilter as string) !== "archived" && (
<NewProjectCard
onClick={() => {
// 快速创建项目,不弹窗
let projectType: ProjectType = "general";
const filter = currentFilter as string;
if (
filter !== "all" &&
filter !== "favorites" &&
filter !== "archived"
) {
projectType = currentFilter as ProjectType;
}
handleQuickCreateProject(projectType);
}}
/>
)}
{/* 项目卡片 */}
{filteredProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onClick={() => handleProjectClick(project)}
onFavorite={() => handleToggleFavorite(project)}
onArchive={() => handleToggleArchive(project)}
onDelete={() => handleDeleteProject(project)}
/>
))}
</div>
)}
</div>
{/* 创建项目对话框 */}
<CreateProjectDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSubmit={handleCreateProject}
defaultType={
// 将当前过滤器转换为项目类型
// "all", "favorites", "archived" 映射到 "general"
// 其他过滤器直接对应项目类型
(["all", "favorites", "archived"].includes(currentFilter)
? "general"
: currentFilter) as ProjectType
}
/>
{/* 删除项目对话框 */}
<DeleteProjectDialog
project={projectToDelete}
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleConfirmDelete}
/>
</div>
);
}
@@ -0,0 +1,184 @@
/**
* 编辑器工具栏
*
* TipTap 编辑器的格式化工具栏
*/
import React from "react";
import { Editor } from "@tiptap/react";
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Minus,
Undo,
Redo,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface EditorToolbarProps {
editor: Editor | null;
className?: string;
}
export function EditorToolbar({ editor, className }: EditorToolbarProps) {
if (!editor) {
return null;
}
return (
<div
className={cn(
"flex items-center gap-1 p-2 border-b bg-muted/30 flex-wrap",
className,
)}
>
{/* 撤销/重做 */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="撤销"
>
<Undo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="重做"
>
<Redo className="h-4 w-4" />
</ToolbarButton>
<Separator orientation="vertical" className="h-6 mx-1" />
{/* 文本格式 */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
title="加粗"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
title="斜体"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive("strike")}
title="删除线"
>
<Strikethrough className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive("code")}
title="行内代码"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<Separator orientation="vertical" className="h-6 mx-1" />
{/* 标题 */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive("heading", { level: 1 })}
title="标题 1"
>
<Heading1 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive("heading", { level: 2 })}
title="标题 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive("heading", { level: 3 })}
title="标题 3"
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<Separator orientation="vertical" className="h-6 mx-1" />
{/* 列表 */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
title="无序列表"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
title="有序列表"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<Separator orientation="vertical" className="h-6 mx-1" />
{/* 其他 */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive("blockquote")}
title="引用"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="分隔线"
>
<Minus className="h-4 w-4" />
</ToolbarButton>
</div>
);
}
// 工具栏按钮组件
interface ToolbarButtonProps {
onClick: () => void;
active?: boolean;
disabled?: boolean;
title?: string;
children: React.ReactNode;
}
function ToolbarButton({
onClick,
active,
disabled,
title,
children,
}: ToolbarButtonProps) {
return (
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", active && "bg-accent text-accent-foreground")}
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</Button>
);
}
@@ -0,0 +1,95 @@
/**
* TipTap 编辑器封装
*
* 基于 TipTap 的富文本编辑器组件
*/
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { useEffect } from "react";
import { cn } from "@/lib/utils";
interface TipTapEditorProps {
content: string;
onChange: (content: string) => void;
placeholder?: string;
className?: string;
editable?: boolean;
}
export function TipTapEditor({
content,
onChange,
placeholder = "开始写作...",
className,
editable = true,
}: TipTapEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty",
}),
],
content,
editable,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
class: cn(
"prose prose-sm sm:prose-base max-w-none focus:outline-none min-h-[200px]",
"prose-headings:font-bold prose-headings:text-foreground",
"prose-p:text-foreground prose-p:leading-relaxed",
"prose-strong:text-foreground prose-strong:font-semibold",
"prose-em:text-foreground",
"prose-ul:text-foreground prose-ol:text-foreground",
"prose-li:text-foreground",
"prose-blockquote:text-muted-foreground prose-blockquote:border-l-primary",
"prose-code:text-foreground prose-code:bg-muted prose-code:px-1 prose-code:rounded",
"prose-pre:bg-muted prose-pre:text-foreground",
),
},
},
});
// 当外部 content 变化时更新编辑器
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
// 当 editable 变化时更新
useEffect(() => {
if (editor) {
editor.setEditable(editable);
}
}, [editable, editor]);
return (
<div
className={cn(
"border rounded-lg bg-background",
"[&_.is-editor-empty:first-child::before]:text-muted-foreground",
"[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
"[&_.is-editor-empty:first-child::before]:float-left",
"[&_.is-editor-empty:first-child::before]:h-0",
"[&_.is-editor-empty:first-child::before]:pointer-events-none",
className,
)}
>
<EditorContent editor={editor} className="p-4" />
</div>
);
}
// 导出 editor 实例类型
export type { Editor } from "@tiptap/react";
+11
View File
@@ -0,0 +1,11 @@
/**
* 项目管理组件导出
*/
export { ProjectsPage } from "./ProjectsPage";
export { ProjectCard } from "./ProjectCard";
export { NewProjectCard } from "./NewProjectCard";
export { ProjectCategories } from "./ProjectCategories";
export { CreateProjectDialog } from "./CreateProjectDialog";
export { ContentListPage } from "./ContentListPage";
export type { ProjectFilter } from "./ProjectCategories";
@@ -0,0 +1,446 @@
/**
* 角色管理面板
*
* 显示角色卡片列表,支持新建、编辑、删除角色
*/
import { useState, useEffect, useCallback } from "react";
import {
Plus,
RefreshCw,
MoreHorizontal,
Edit2,
Trash2,
Star,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import {
Character,
CreateCharacterRequest,
UpdateCharacterRequest,
listCharacters,
createCharacter,
updateCharacter,
deleteCharacter,
} from "@/lib/api/memory";
import { toast } from "sonner";
interface CharacterPanelProps {
projectId: string;
}
interface CharacterFormData {
name: string;
aliases: string;
description: string;
personality: string;
background: string;
appearance: string;
is_main: boolean;
}
const emptyFormData: CharacterFormData = {
name: "",
aliases: "",
description: "",
personality: "",
background: "",
appearance: "",
is_main: false,
};
export function CharacterPanel({ projectId }: CharacterPanelProps) {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCharacter, setEditingCharacter] = useState<Character | null>(
null,
);
const [formData, setFormData] = useState<CharacterFormData>(emptyFormData);
const [saving, setSaving] = useState(false);
const loadCharacters = useCallback(async () => {
setLoading(true);
try {
const list = await listCharacters(projectId);
setCharacters(list);
} catch (error) {
console.error("加载角色失败:", error);
toast.error("加载角色失败");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadCharacters();
}, [loadCharacters]);
const handleOpenCreate = () => {
setEditingCharacter(null);
setFormData(emptyFormData);
setDialogOpen(true);
};
const handleOpenEdit = (character: Character) => {
setEditingCharacter(character);
setFormData({
name: character.name,
aliases: character.aliases.join(", "),
description: character.description || "",
personality: character.personality || "",
background: character.background || "",
appearance: character.appearance || "",
is_main: character.is_main,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.name.trim()) {
toast.error("请输入角色名称");
return;
}
setSaving(true);
try {
const aliases = formData.aliases
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (editingCharacter) {
const request: UpdateCharacterRequest = {
name: formData.name,
aliases,
description: formData.description || undefined,
personality: formData.personality || undefined,
background: formData.background || undefined,
appearance: formData.appearance || undefined,
is_main: formData.is_main,
};
await updateCharacter(editingCharacter.id, request);
toast.success("角色已更新");
} else {
const request: CreateCharacterRequest = {
project_id: projectId,
name: formData.name,
aliases,
description: formData.description || undefined,
personality: formData.personality || undefined,
background: formData.background || undefined,
appearance: formData.appearance || undefined,
is_main: formData.is_main,
};
await createCharacter(request);
toast.success("角色已创建");
}
setDialogOpen(false);
loadCharacters();
} catch (error) {
console.error("保存角色失败:", error);
toast.error("保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async (character: Character) => {
if (!confirm(`确定要删除角色 "${character.name}" 吗?`)) {
return;
}
try {
await deleteCharacter(character.id);
toast.success("角色已删除");
loadCharacters();
} catch (error) {
console.error("删除角色失败:", error);
toast.error("删除失败");
}
};
// 分离主角和配角
const mainCharacters = characters.filter((c) => c.is_main);
const sideCharacters = characters.filter((c) => !c.is_main);
return (
<div className="flex flex-col h-full">
{/* 工具栏 */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
共 {characters.length} 个角色
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={loadCharacters}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-2" />
新建角色
</Button>
</div>
</div>
{/* 角色列表 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-40">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : characters.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
<User className="h-12 w-12 mb-4 opacity-50" />
<p className="mb-4">还没有角色</p>
<Button onClick={handleOpenCreate}>创建第一个角色</Button>
</div>
) : (
<div className="space-y-6">
{/* 主角 */}
{mainCharacters.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<Star className="h-4 w-4" />
主要角色
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mainCharacters.map((character) => (
<CharacterCard
key={character.id}
character={character}
onEdit={() => handleOpenEdit(character)}
onDelete={() => handleDelete(character)}
/>
))}
</div>
</div>
)}
{/* 配角 */}
{sideCharacters.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<User className="h-4 w-4" />
次要角色
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sideCharacters.map((character) => (
<CharacterCard
key={character.id}
character={character}
onEdit={() => handleOpenEdit(character)}
onDelete={() => handleDelete(character)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* 新建/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingCharacter ? "编辑角色" : "新建角色"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4 max-h-[60vh] overflow-auto">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">角色名称 *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="输入角色名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="aliases">别名(逗号分隔)</Label>
<Input
id="aliases"
value={formData.aliases}
onChange={(e) =>
setFormData({ ...formData, aliases: e.target.value })
}
placeholder="小明, 阿明"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="is_main"
checked={formData.is_main}
onCheckedChange={(checked) =>
setFormData({ ...formData, is_main: checked })
}
/>
<Label htmlFor="is_main">主要角色</Label>
</div>
<div className="space-y-2">
<Label htmlFor="description">角色简介</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="简要描述这个角色"
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="personality">性格特点</Label>
<Textarea
id="personality"
value={formData.personality}
onChange={(e) =>
setFormData({ ...formData, personality: e.target.value })
}
placeholder="描述角色的性格特点"
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="background">背景故事</Label>
<Textarea
id="background"
value={formData.background}
onChange={(e) =>
setFormData({ ...formData, background: e.target.value })
}
placeholder="角色的背景故事"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="appearance">外貌描述</Label>
<Textarea
id="appearance"
value={formData.appearance}
onChange={(e) =>
setFormData({ ...formData, appearance: e.target.value })
}
placeholder="角色的外貌特征"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// 角色卡片组件
interface CharacterCardProps {
character: Character;
onEdit: () => void;
onDelete: () => void;
}
function CharacterCard({ character, onEdit, onDelete }: CharacterCardProps) {
return (
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<h4 className="font-medium flex items-center gap-1">
{character.name}
{character.is_main && (
<Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />
)}
</h4>
{character.aliases.length > 0 && (
<p className="text-xs text-muted-foreground">
{character.aliases.join(", ")}
</p>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{character.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{character.description}
</p>
)}
{character.personality && (
<div className="mt-2">
<span className="text-xs text-muted-foreground">性格:</span>
<span className="text-xs">{character.personality}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,461 @@
/**
* 大纲管理面板
*
* 显示和编辑项目大纲,支持树形结构和拖拽排序
*/
import { useState, useEffect, useCallback } from "react";
import {
Plus,
RefreshCw,
MoreHorizontal,
Edit2,
Trash2,
ChevronRight,
ChevronDown,
GripVertical,
ArrowUp,
ArrowDown,
FileText,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import {
OutlineNode,
CreateOutlineNodeRequest,
UpdateOutlineNodeRequest,
listOutlineNodes,
createOutlineNode,
updateOutlineNode,
deleteOutlineNode,
buildOutlineTree,
} from "@/lib/api/memory";
import { toast } from "sonner";
interface OutlinePanelProps {
projectId: string;
}
type OutlineTreeNode = OutlineNode & { children: OutlineTreeNode[] };
interface NodeFormData {
title: string;
content: string;
parent_id: string | null;
}
const emptyFormData: NodeFormData = {
title: "",
content: "",
parent_id: null,
};
export function OutlinePanel({ projectId }: OutlinePanelProps) {
const [nodes, setNodes] = useState<OutlineNode[]>([]);
const [tree, setTree] = useState<OutlineTreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<OutlineNode | null>(null);
const [formData, setFormData] = useState<NodeFormData>(emptyFormData);
const [saving, setSaving] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const loadNodes = useCallback(async () => {
setLoading(true);
try {
const list = await listOutlineNodes(projectId);
setNodes(list);
const treeData = buildOutlineTree(list) as OutlineTreeNode[];
setTree(treeData);
// 默认展开所有节点
const allIds = new Set(list.map((n) => n.id));
setExpandedNodes(allIds);
} catch (error) {
console.error("加载大纲失败:", error);
toast.error("加载大纲失败");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadNodes();
}, [loadNodes]);
const toggleExpand = (nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
};
const handleOpenCreate = (parentId: string | null = null) => {
setEditingNode(null);
setFormData({ ...emptyFormData, parent_id: parentId });
setDialogOpen(true);
};
const handleOpenEdit = (node: OutlineNode) => {
setEditingNode(node);
setFormData({
title: node.title,
content: node.content || "",
parent_id: node.parent_id || null,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.title.trim()) {
toast.error("请输入节点标题");
return;
}
setSaving(true);
try {
if (editingNode) {
const request: UpdateOutlineNodeRequest = {
title: formData.title,
content: formData.content || undefined,
};
await updateOutlineNode(editingNode.id, request);
toast.success("节点已更新");
} else {
const request: CreateOutlineNodeRequest = {
project_id: projectId,
parent_id: formData.parent_id || undefined,
title: formData.title,
content: formData.content || undefined,
};
await createOutlineNode(request);
toast.success("节点已创建");
}
setDialogOpen(false);
loadNodes();
} catch (error) {
console.error("保存节点失败:", error);
toast.error("保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async (node: OutlineNode) => {
// 检查是否有子节点
const hasChildren = nodes.some((n) => n.parent_id === node.id);
const message = hasChildren
? `确定要删除 "${node.title}" 及其所有子节点吗?`
: `确定要删除 "${node.title}" 吗?`;
if (!confirm(message)) {
return;
}
try {
await deleteOutlineNode(node.id);
toast.success("节点已删除");
loadNodes();
} catch (error) {
console.error("删除节点失败:", error);
toast.error("删除失败");
}
};
const handleMoveUp = async (node: OutlineNode) => {
// 找到同级节点
const siblings = nodes
.filter((n) => n.parent_id === node.parent_id)
.sort((a, b) => a.order - b.order);
const index = siblings.findIndex((n) => n.id === node.id);
if (index <= 0) return;
const prevNode = siblings[index - 1];
try {
await Promise.all([
updateOutlineNode(node.id, { order: prevNode.order }),
updateOutlineNode(prevNode.id, { order: node.order }),
]);
loadNodes();
} catch (error) {
console.error("移动节点失败:", error);
toast.error("移动失败");
}
};
const handleMoveDown = async (node: OutlineNode) => {
// 找到同级节点
const siblings = nodes
.filter((n) => n.parent_id === node.parent_id)
.sort((a, b) => a.order - b.order);
const index = siblings.findIndex((n) => n.id === node.id);
if (index >= siblings.length - 1) return;
const nextNode = siblings[index + 1];
try {
await Promise.all([
updateOutlineNode(node.id, { order: nextNode.order }),
updateOutlineNode(nextNode.id, { order: node.order }),
]);
loadNodes();
} catch (error) {
console.error("移动节点失败:", error);
toast.error("移动失败");
}
};
return (
<div className="flex flex-col h-full">
{/* 工具栏 */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
共 {nodes.length} 个节点
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={loadNodes}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button onClick={() => handleOpenCreate(null)}>
<Plus className="h-4 w-4 mr-2" />
新建节点
</Button>
</div>
</div>
{/* 大纲树 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-40">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tree.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
<FileText className="h-12 w-12 mb-4 opacity-50" />
<p className="mb-4">还没有大纲</p>
<Button onClick={() => handleOpenCreate(null)}>
创建第一个节点
</Button>
</div>
) : (
<div className="space-y-1">
{tree.map((node) => (
<OutlineTreeItem
key={node.id}
node={node}
level={0}
expandedNodes={expandedNodes}
onToggleExpand={toggleExpand}
onEdit={handleOpenEdit}
onDelete={handleDelete}
onAddChild={(parentId) => handleOpenCreate(parentId)}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
</div>
)}
</div>
{/* 新建/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingNode ? "编辑节点" : "新建节点"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="title">节点标题 *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
placeholder="输入节点标题"
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">节点内容</Label>
<Textarea
id="content"
value={formData.content}
onChange={(e) =>
setFormData({ ...formData, content: e.target.value })
}
placeholder="输入节点内容或描述"
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// 大纲树节点组件
interface OutlineTreeItemProps {
node: OutlineTreeNode;
level: number;
expandedNodes: Set<string>;
onToggleExpand: (nodeId: string) => void;
onEdit: (node: OutlineNode) => void;
onDelete: (node: OutlineNode) => void;
onAddChild: (parentId: string) => void;
onMoveUp: (node: OutlineNode) => void;
onMoveDown: (node: OutlineNode) => void;
}
function OutlineTreeItem({
node,
level,
expandedNodes,
onToggleExpand,
onEdit,
onDelete,
onAddChild,
onMoveUp,
onMoveDown,
}: OutlineTreeItemProps) {
const hasChildren = node.children.length > 0;
const isExpanded = expandedNodes.has(node.id);
return (
<div>
<div
className={cn(
"flex items-center gap-1 py-1.5 px-2 rounded hover:bg-accent/50 group",
level > 0 && "ml-4",
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{/* 展开/折叠按钮 */}
<button
onClick={() => onToggleExpand(node.id)}
className={cn(
"p-0.5 rounded hover:bg-accent",
!hasChildren && "invisible",
)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{/* 拖拽手柄 */}
<GripVertical className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 cursor-grab" />
{/* 标题 */}
<span className="flex-1 text-sm truncate">{node.title}</span>
{/* 操作按钮 */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onMoveUp(node)}
title="上移"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onMoveDown(node)}
title="下移"
>
<ArrowDown className="h-3 w-3" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(node)}>
<Edit2 className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAddChild(node.id)}>
<Plus className="h-4 w-4 mr-2" />
添加子节点
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(node)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* 子节点 */}
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<OutlineTreeItem
key={child.id}
node={child}
level={level + 1}
expandedNodes={expandedNodes}
onToggleExpand={onToggleExpand}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,298 @@
/**
* 风格指南面板
*
* 编辑项目的写作风格指南
*/
import { useState, useEffect, useCallback, KeyboardEvent } from "react";
import { RefreshCw, Save, FileEdit, X, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
StyleGuide,
UpdateStyleGuideRequest,
getStyleGuide,
updateStyleGuide,
} from "@/lib/api/memory";
import { toast } from "sonner";
interface StyleGuidePanelProps {
projectId: string;
}
interface FormData {
style: string;
tone: string;
forbidden_words: string[];
preferred_words: string[];
examples: string;
}
const emptyFormData: FormData = {
style: "",
tone: "",
forbidden_words: [],
preferred_words: [],
examples: "",
};
export function StyleGuidePanel({ projectId }: StyleGuidePanelProps) {
const [_styleGuide, setStyleGuide] = useState<StyleGuide | null>(null);
const [formData, setFormData] = useState<FormData>(emptyFormData);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const loadStyleGuide = useCallback(async () => {
setLoading(true);
try {
const data = await getStyleGuide(projectId);
setStyleGuide(data);
if (data) {
setFormData({
style: data.style || "",
tone: data.tone || "",
forbidden_words: data.forbidden_words || [],
preferred_words: data.preferred_words || [],
examples: data.examples || "",
});
} else {
setFormData(emptyFormData);
}
setHasChanges(false);
} catch (error) {
console.error("加载风格指南失败:", error);
toast.error("加载风格指南失败");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadStyleGuide();
}, [loadStyleGuide]);
const handleChange = <K extends keyof FormData>(
field: K,
value: FormData[K],
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setHasChanges(true);
};
const handleSave = async () => {
setSaving(true);
try {
const request: UpdateStyleGuideRequest = {
style: formData.style || undefined,
tone: formData.tone || undefined,
forbidden_words:
formData.forbidden_words.length > 0
? formData.forbidden_words
: undefined,
preferred_words:
formData.preferred_words.length > 0
? formData.preferred_words
: undefined,
examples: formData.examples || undefined,
};
const updated = await updateStyleGuide(projectId, request);
setStyleGuide(updated);
setHasChanges(false);
toast.success("风格指南已保存");
} catch (error) {
console.error("保存风格指南失败:", error);
toast.error("保存失败");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-40">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 工具栏 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-muted-foreground">
<FileEdit className="h-4 w-4" />
<span className="text-sm">风格指南</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={loadStyleGuide}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button onClick={handleSave} disabled={saving || !hasChanges}>
<Save className="h-4 w-4 mr-2" />
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
{/* 表单 */}
<div className="flex-1 overflow-auto space-y-6">
<div className="space-y-2">
<Label htmlFor="style">写作风格</Label>
<Textarea
id="style"
value={formData.style}
onChange={(e) => handleChange("style", e.target.value)}
placeholder="描述整体写作风格..."
rows={3}
/>
<p className="text-xs text-muted-foreground">
描述整体的写作风格,如简洁明快、细腻抒情等
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tone">语气/调性</Label>
<Textarea
id="tone"
value={formData.tone}
onChange={(e) => handleChange("tone", e.target.value)}
placeholder="描述语气和调性..."
rows={2}
/>
<p className="text-xs text-muted-foreground">
描述文字的语气,如幽默、严肃、温暖等
</p>
</div>
<div className="space-y-2">
<Label>禁用词汇</Label>
<TagInput
value={formData.forbidden_words}
onChange={(words) => handleChange("forbidden_words", words)}
placeholder="输入后按回车添加"
/>
<p className="text-xs text-muted-foreground">
避免在写作中使用的词汇
</p>
</div>
<div className="space-y-2">
<Label>偏好词汇</Label>
<TagInput
value={formData.preferred_words}
onChange={(words) => handleChange("preferred_words", words)}
placeholder="输入后按回车添加"
/>
<p className="text-xs text-muted-foreground">
推荐在写作中使用的词汇
</p>
</div>
<div className="space-y-2">
<Label htmlFor="examples">示例文本</Label>
<Textarea
id="examples"
value={formData.examples}
onChange={(e) => handleChange("examples", e.target.value)}
placeholder="提供一些符合风格的示例文本..."
rows={5}
/>
<p className="text-xs text-muted-foreground">
提供一些符合风格的示例文本,帮助 AI 理解风格
</p>
</div>
</div>
{/* 状态提示 */}
{hasChanges && (
<div className="mt-4 text-sm text-muted-foreground text-center">
有未保存的更改
</div>
)}
</div>
);
}
// 简单的标签输入组件
interface TagInputProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
maxTags?: number;
}
function TagInput({
value,
onChange,
placeholder = "输入后按回车添加",
maxTags = 20,
}: TagInputProps) {
const [inputValue, setInputValue] = useState("");
const addTag = () => {
const trimmed = inputValue.trim();
if (trimmed && !value.includes(trimmed) && value.length < maxTags) {
onChange([...value, trimmed]);
setInputValue("");
}
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
removeTag(value[value.length - 1]);
}
};
return (
<div className="flex flex-wrap gap-2 p-2 border rounded-md bg-background min-h-[44px] focus-within:ring-2 focus-within:ring-ring">
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded text-sm"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="hover:text-primary/80"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
disabled={value.length >= maxTags}
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-sm"
/>
{inputValue && (
<button
type="button"
onClick={addTag}
className="p-1 hover:bg-primary/10 rounded"
>
<Plus className="h-4 w-4 text-muted-foreground" />
</button>
)}
</div>
);
}
@@ -0,0 +1,201 @@
/**
* 世界观编辑面板
*
* 编辑项目的世界观设定
*/
import { useState, useEffect, useCallback } from "react";
import { RefreshCw, Save, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
WorldBuilding,
UpdateWorldBuildingRequest,
getWorldBuilding,
updateWorldBuilding,
} from "@/lib/api/memory";
import { toast } from "sonner";
interface WorldBuildingPanelProps {
projectId: string;
}
interface FormData {
description: string;
era: string;
locations: string;
rules: string;
}
const emptyFormData: FormData = {
description: "",
era: "",
locations: "",
rules: "",
};
export function WorldBuildingPanel({ projectId }: WorldBuildingPanelProps) {
const [_worldBuilding, setWorldBuilding] = useState<WorldBuilding | null>(
null,
);
const [formData, setFormData] = useState<FormData>(emptyFormData);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const loadWorldBuilding = useCallback(async () => {
setLoading(true);
try {
const data = await getWorldBuilding(projectId);
setWorldBuilding(data);
if (data) {
setFormData({
description: data.description || "",
era: data.era || "",
locations: data.locations || "",
rules: data.rules || "",
});
} else {
setFormData(emptyFormData);
}
setHasChanges(false);
} catch (error) {
console.error("加载世界观失败:", error);
toast.error("加载世界观失败");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadWorldBuilding();
}, [loadWorldBuilding]);
const handleChange = (field: keyof FormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setHasChanges(true);
};
const handleSave = async () => {
setSaving(true);
try {
const request: UpdateWorldBuildingRequest = {
description: formData.description || undefined,
era: formData.era || undefined,
locations: formData.locations || undefined,
rules: formData.rules || undefined,
};
const updated = await updateWorldBuilding(projectId, request);
setWorldBuilding(updated);
setHasChanges(false);
toast.success("世界观已保存");
} catch (error) {
console.error("保存世界观失败:", error);
toast.error("保存失败");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-40">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 工具栏 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4" />
<span className="text-sm">世界观设定</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={loadWorldBuilding}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button onClick={handleSave} disabled={saving || !hasChanges}>
<Save className="h-4 w-4 mr-2" />
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
{/* 表单 */}
<div className="flex-1 overflow-auto space-y-6">
<div className="space-y-2">
<Label htmlFor="description">世界观描述</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="描述故事发生的世界背景..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
整体描述故事发生的世界,包括基本设定和核心概念
</p>
</div>
<div className="space-y-2">
<Label htmlFor="era">时代背景</Label>
<Textarea
id="era"
value={formData.era}
onChange={(e) => handleChange("era", e.target.value)}
placeholder="故事发生的时代..."
rows={3}
/>
<p className="text-xs text-muted-foreground">
描述故事发生的时代,如现代、古代、未来等
</p>
</div>
<div className="space-y-2">
<Label htmlFor="locations">地点设定</Label>
<Textarea
id="locations"
value={formData.locations}
onChange={(e) => handleChange("locations", e.target.value)}
placeholder="主要地点和场景..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
描述故事中的主要地点、城市、场景等
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rules">规则/设定</Label>
<Textarea
id="rules"
value={formData.rules}
onChange={(e) => handleChange("rules", e.target.value)}
placeholder="世界运行的规则..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
描述世界运行的规则,如魔法体系、科技水平、社会制度等
</p>
</div>
</div>
{/* 状态提示 */}
{hasChanges && (
<div className="mt-4 text-sm text-muted-foreground text-center">
有未保存的更改
</div>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Memory 组件导出入口
*/
export { CharacterPanel } from "./CharacterPanel";
export { WorldBuildingPanel } from "./WorldBuildingPanel";
export { StyleGuidePanel } from "./StyleGuidePanel";
export { OutlinePanel } from "./OutlinePanel";
+15 -1
View File
@@ -65,19 +65,33 @@ const DialogTrigger: React.FC<DialogTriggerProps> = ({ asChild, children }) => {
interface DialogContentProps {
className?: string;
children: React.ReactNode;
maxWidth?: string;
}
const DialogContent: React.FC<DialogContentProps> = ({
className,
children,
maxWidth,
}) => {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogContent must be used within Dialog");
const { open, setOpen } = context;
// 从 className 中提取 max-w 类
const maxWidthFromClass = className?.match(/(?:sm:)?max-w-\[?\w+\]?/)?.[0];
const finalMaxWidth = maxWidth || maxWidthFromClass || "max-w-lg";
const filteredClassName = className
?.replace(/(?:sm:)?max-w-\[?\w+\]?/g, "")
.trim();
return (
<Modal isOpen={open} onClose={() => setOpen(false)} className={className}>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className={cn("p-6", filteredClassName)}
maxWidth={finalMaxWidth}
>
{children}
</Modal>
);
+25 -7
View File
@@ -58,15 +58,31 @@ const DropdownMenuTrigger: React.FC<DropdownMenuTriggerProps> = ({
if (!context)
throw new Error("DropdownMenuTrigger must be used within DropdownMenu");
const { setOpen } = context;
const { open, setOpen } = context;
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: () => setOpen(true),
const childProps = children.props as {
onClick?: (e: React.MouseEvent) => void;
};
return React.cloneElement(children as React.ReactElement, {
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
childProps.onClick?.(e);
setOpen(!open);
},
});
}
return <button onClick={() => setOpen(true)}>{children}</button>;
return (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(!open);
}}
>
{children}
</button>
);
};
interface DropdownMenuContentProps {
@@ -113,10 +129,11 @@ const DropdownMenuContent: React.FC<DropdownMenuContentProps> = ({
<div
ref={ref}
className={cn(
"absolute top-full z-50 mt-1 min-w-32 rounded-md border bg-white shadow-md",
"absolute top-full z-50 mt-1 min-w-32 rounded-md border bg-popover text-popover-foreground shadow-md",
alignmentClasses[align],
className,
)}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
@@ -140,7 +157,8 @@ const DropdownMenuItem: React.FC<DropdownMenuItemProps> = ({
const { setOpen } = context;
const handleClick = () => {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
setOpen(false);
};
@@ -148,7 +166,7 @@ const DropdownMenuItem: React.FC<DropdownMenuItemProps> = ({
return (
<div
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-gray-100",
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground",
className,
)}
onClick={handleClick}
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+3 -2
View File
@@ -6,12 +6,13 @@
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { ProjectType } from "@/lib/api/project";
/** Workspace 列表项 */
export interface Workspace {
id: string;
name: string;
workspaceType: "persistent" | "temporary";
workspaceType: ProjectType;
rootPath: string;
isDefault: boolean;
createdAt: number;
@@ -29,7 +30,7 @@ export interface WorkspaceSettings {
export interface CreateWorkspaceRequest {
name: string;
rootPath: string;
workspaceType?: "persistent" | "temporary";
workspaceType?: ProjectType;
}
/** 更新 Workspace 请求 */
+21
View File
@@ -176,3 +176,24 @@
opacity: 0;
}
}
/* Artifact 流式进度动画 */
/* @requirements 11.1 */
@keyframes streaming-progress {
0% {
width: 0%;
transform: translateX(0);
}
50% {
width: 100%;
transform: translateX(0);
}
100% {
width: 100%;
transform: translateX(100%);
}
}
.animate-streaming-progress {
animation: streaming-progress 1.5s ease-in-out infinite;
}
+2
View File
@@ -9,6 +9,8 @@
## 文件索引
- `artifact/` - Artifact 系统核心库(Requirements 1.1-1.5)
- `types.ts` - Artifact 类型定义
- `api/` - API 调用封装
- `apiKeyProvider.ts` - API Key Provider API 封装(Requirements 9.1)
- `pluginUI.ts` - 插件 UI API(Requirements 3.1)
+23
View File
@@ -283,6 +283,7 @@ export interface SessionInfo {
session_id: string;
provider_type: string;
model?: string;
title?: string;
created_at: string;
last_activity: string;
messages_count: number;
@@ -476,6 +477,28 @@ export async function getAgentSessionMessages(
});
}
/**
* 重命名会话(更新标题)
*/
export async function renameAgentSession(
sessionId: string,
title: string,
): Promise<void> {
return await safeInvoke("agent_rename_session", {
sessionId,
title,
});
}
/**
* 生成智能标题
*/
export async function generateAgentTitle(sessionId: string): Promise<string> {
return await safeInvoke("agent_generate_title", {
sessionId,
});
}
// ============================================================
// aster Agent API (基于 aster 框架的完整 Agent 实现)
// ============================================================
+298
View File
@@ -0,0 +1,298 @@
/**
* 记忆系统 API
*
* 提供角色、世界观、风格指南、大纲的 CRUD 操作
*/
import { invoke } from "@tauri-apps/api/core";
// ==================== 类型定义 ====================
/** 角色关系 */
export interface CharacterRelationship {
target_id: string;
relationship_type: string;
description?: string;
}
/** 角色 */
export interface Character {
id: string;
project_id: string;
name: string;
aliases: string[];
description?: string;
personality?: string;
background?: string;
appearance?: string;
relationships: CharacterRelationship[];
avatar_url?: string;
is_main: boolean;
order: number;
extra?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
/** 创建角色请求 */
export interface CreateCharacterRequest {
project_id: string;
name: string;
aliases?: string[];
description?: string;
personality?: string;
background?: string;
appearance?: string;
is_main?: boolean;
}
/** 更新角色请求 */
export interface UpdateCharacterRequest {
name?: string;
aliases?: string[];
description?: string;
personality?: string;
background?: string;
appearance?: string;
relationships?: CharacterRelationship[];
avatar_url?: string;
is_main?: boolean;
order?: number;
extra?: Record<string, unknown>;
}
/** 世界观设定 */
export interface WorldBuilding {
project_id: string;
description: string;
era?: string;
locations?: string;
rules?: string;
extra?: Record<string, unknown>;
updated_at: string;
}
/** 更新世界观请求 */
export interface UpdateWorldBuildingRequest {
description?: string;
era?: string;
locations?: string;
rules?: string;
extra?: Record<string, unknown>;
}
/** 风格指南 */
export interface StyleGuide {
project_id: string;
style: string;
tone?: string;
forbidden_words: string[];
preferred_words: string[];
examples?: string;
extra?: Record<string, unknown>;
updated_at: string;
}
/** 更新风格指南请求 */
export interface UpdateStyleGuideRequest {
style?: string;
tone?: string;
forbidden_words?: string[];
preferred_words?: string[];
examples?: string;
extra?: Record<string, unknown>;
}
/** 大纲节点 */
export interface OutlineNode {
id: string;
project_id: string;
parent_id?: string;
title: string;
content?: string;
content_id?: string;
order: number;
expanded: boolean;
extra?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
/** 创建大纲节点请求 */
export interface CreateOutlineNodeRequest {
project_id: string;
parent_id?: string;
title: string;
content?: string;
content_id?: string;
order?: number;
}
/** 更新大纲节点请求 */
export interface UpdateOutlineNodeRequest {
parent_id?: string | null;
title?: string;
content?: string;
content_id?: string | null;
order?: number;
expanded?: boolean;
extra?: Record<string, unknown>;
}
/** 项目记忆(聚合) */
export interface ProjectMemory {
characters: Character[];
world_building?: WorldBuilding;
style_guide?: StyleGuide;
outline: OutlineNode[];
}
// ==================== 角色 API ====================
/** 获取角色列表 */
export async function listCharacters(projectId: string): Promise<Character[]> {
return invoke("character_list", { projectId });
}
/** 获取角色详情 */
export async function getCharacter(id: string): Promise<Character | null> {
return invoke("character_get", { id });
}
/** 创建角色 */
export async function createCharacter(
request: CreateCharacterRequest,
): Promise<Character> {
return invoke("character_create", { request });
}
/** 更新角色 */
export async function updateCharacter(
id: string,
request: UpdateCharacterRequest,
): Promise<Character> {
return invoke("character_update", { id, request });
}
/** 删除角色 */
export async function deleteCharacter(id: string): Promise<boolean> {
return invoke("character_delete", { id });
}
// ==================== 世界观 API ====================
/** 获取世界观 */
export async function getWorldBuilding(
projectId: string,
): Promise<WorldBuilding | null> {
return invoke("world_building_get", { projectId });
}
/** 更新世界观 */
export async function updateWorldBuilding(
projectId: string,
request: UpdateWorldBuildingRequest,
): Promise<WorldBuilding> {
return invoke("world_building_update", { projectId, request });
}
// ==================== 风格指南 API ====================
/** 获取风格指南 */
export async function getStyleGuide(
projectId: string,
): Promise<StyleGuide | null> {
return invoke("style_guide_get", { projectId });
}
/** 更新风格指南 */
export async function updateStyleGuide(
projectId: string,
request: UpdateStyleGuideRequest,
): Promise<StyleGuide> {
return invoke("style_guide_update", { projectId, request });
}
// ==================== 大纲 API ====================
/** 获取大纲节点列表 */
export async function listOutlineNodes(
projectId: string,
): Promise<OutlineNode[]> {
return invoke("outline_node_list", { projectId });
}
/** 获取大纲节点详情 */
export async function getOutlineNode(id: string): Promise<OutlineNode | null> {
return invoke("outline_node_get", { id });
}
/** 创建大纲节点 */
export async function createOutlineNode(
request: CreateOutlineNodeRequest,
): Promise<OutlineNode> {
return invoke("outline_node_create", { request });
}
/** 更新大纲节点 */
export async function updateOutlineNode(
id: string,
request: UpdateOutlineNodeRequest,
): Promise<OutlineNode> {
return invoke("outline_node_update", { id, request });
}
/** 删除大纲节点 */
export async function deleteOutlineNode(id: string): Promise<boolean> {
return invoke("outline_node_delete", { id });
}
// ==================== 聚合 API ====================
/** 获取项目完整记忆 */
export async function getProjectMemory(
projectId: string,
): Promise<ProjectMemory> {
return invoke("project_memory_get", { projectId });
}
// ==================== 辅助函数 ====================
/** 构建大纲树结构 */
export function buildOutlineTree(
nodes: OutlineNode[],
): (OutlineNode & { children: OutlineNode[] })[] {
const nodeMap = new Map<string, OutlineNode & { children: OutlineNode[] }>();
const roots: (OutlineNode & { children: OutlineNode[] })[] = [];
// 初始化所有节点
nodes.forEach((node) => {
nodeMap.set(node.id, { ...node, children: [] });
});
// 构建树结构
nodes.forEach((node) => {
const current = nodeMap.get(node.id)!;
if (node.parent_id) {
const parent = nodeMap.get(node.parent_id);
if (parent) {
parent.children.push(current);
} else {
roots.push(current);
}
} else {
roots.push(current);
}
});
// 按 order 排序
const sortByOrder = (
items: (OutlineNode & { children: OutlineNode[] })[],
) => {
items.sort((a, b) => a.order - b.order);
items.forEach((item) => sortByOrder(item.children as any));
};
sortByOrder(roots);
return roots;
}

Some files were not shown because too many files have changed in this diff Show More