mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: v0.51.0 - Artifact System 集成与 Content Creator 增强
主要更新: - 集成 Artifact 系统到聊天界面,支持代码块折叠和画布预览 - 新增 Novel/Script Canvas 类型支持 - 新增 Projects 模块(项目管理、内容编辑、记忆系统) - 新增 Memory 系统(角色、大纲、风格指南、世界观) - 新增 Reasoning Handler 支持思维链处理 - 优化 Workspace 管理和 Agent 类型定义
This commit is contained in:
+3
-1
@@ -56,4 +56,6 @@ src-tauri/gen
|
||||
.task
|
||||
Taskfile.yml
|
||||
nul
|
||||
.proptest-regressions
|
||||
.proptest-regressions
|
||||
.codex
|
||||
openspec
|
||||
+8
-1
@@ -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",
|
||||
|
||||
Generated
+55
-13
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
/// 最后活动时间
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Content 模块
|
||||
//!
|
||||
//! 提供项目内容管理功能。
|
||||
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
|
||||
pub use manager::ContentManager;
|
||||
pub use types::*;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Memory 模块
|
||||
//!
|
||||
//! 提供项目记忆系统管理功能(角色、世界观、风格指南、大纲)。
|
||||
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
|
||||
pub use manager::MemoryManager;
|
||||
pub use types::*;
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, // 统计信息需要单独查询
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, // 智能标题生成
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Memory 组件导出入口
|
||||
*/
|
||||
|
||||
export { CharacterPanel } from "./CharacterPanel";
|
||||
export { WorldBuildingPanel } from "./WorldBuildingPanel";
|
||||
export { StyleGuidePanel } from "./StyleGuidePanel";
|
||||
export { OutlinePanel } from "./OutlinePanel";
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 请求 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 实现)
|
||||
// ============================================================
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user