mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: bump version to 0.65.0
主要更新: - 优化 WorkbenchPage 导航显示,项目管理按钮显示主题上下文 - 修复双重背景问题,移除冗余的背景色和边框 - 移除 NovelCanvas 中的 HeaderTitle 和横线 - 修复 MCP 相关测试的编译错误(添加 meta 字段) - 修复 scheduler 测试的导入问题 - 代码格式化和 lint 修复
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.64.0",
|
||||
"version": "0.65.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+15
-15
@@ -6621,7 +6621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -6717,7 +6717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"aster",
|
||||
"async-trait",
|
||||
@@ -6740,7 +6740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -6756,7 +6756,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
@@ -6795,7 +6795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -6813,7 +6813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -6833,7 +6833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"glob",
|
||||
@@ -6848,7 +6848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -6867,7 +6867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -6919,7 +6919,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum 0.7.9",
|
||||
@@ -6958,7 +6958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -6973,7 +6973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster",
|
||||
@@ -7014,7 +7014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7030,7 +7030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7057,7 +7057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
edition = "2021"
|
||||
authors = ["you"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -117,8 +117,7 @@ enigo = "0.3"
|
||||
# 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库
|
||||
# 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/)
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.11.0"
|
||||
# aster = { version = "0.11.0", path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.11.0" }
|
||||
aster = { path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
|
||||
@@ -181,7 +180,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.64.0"
|
||||
version = "0.65.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Ask 工具桥接
|
||||
//!
|
||||
//! 将 aster 的 AskTool 回调桥接到 ActionRequiredManager,
|
||||
//! 通过 elicitation 事件把问题发送到前端并等待用户输入。
|
||||
|
||||
use aster::action_required_manager::ActionRequiredManager;
|
||||
use aster::tools::AskCallback;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_ASK_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// 创建 AskTool 回调
|
||||
pub fn create_ask_callback() -> AskCallback {
|
||||
std::sync::Arc::new(|question: String, options: Option<Vec<String>>| {
|
||||
Box::pin(async move {
|
||||
let requested_schema = build_requested_schema(&question, options.as_deref());
|
||||
|
||||
match ActionRequiredManager::global()
|
||||
.request_and_wait(
|
||||
question.clone(),
|
||||
requested_schema,
|
||||
Duration::from_secs(DEFAULT_ASK_TIMEOUT_SECS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user_data) => extract_response(&user_data),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][AskBridge] 用户输入等待失败: question='{}', err={}",
|
||||
question,
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建 elicitation 的请求 schema
|
||||
fn build_requested_schema(question: &str, options: Option<&[String]>) -> Value {
|
||||
if let Some(options) = options {
|
||||
let options: Vec<Value> = options.iter().map(|item| json!(item)).collect();
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"description": question,
|
||||
"enum": options
|
||||
},
|
||||
"other": {
|
||||
"type": "string",
|
||||
"description": "可选:自由输入答案"
|
||||
}
|
||||
},
|
||||
"required": ["answer"]
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"description": question
|
||||
}
|
||||
},
|
||||
"required": ["answer"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 从前端回传的 user_data 中提取可用于 AskTool 的字符串答案
|
||||
pub fn extract_response(user_data: &Value) -> Option<String> {
|
||||
match user_data {
|
||||
Value::String(s) => {
|
||||
let value = s.trim();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::String(other)) = map.get("other") {
|
||||
let trimmed = other.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Value::String(answer)) = map.get("answer") {
|
||||
let trimmed = answer.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 ask_user 场景可能返回的任意对象,降级为 JSON 字符串
|
||||
serde_json::to_string(user_data)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
_ => serde_json::to_string(user_data)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,9 @@ impl AsterAgentState {
|
||||
let session_store = Arc::new(ProxyCastSessionStore::new(db.clone()));
|
||||
tracing::info!("[AsterAgent] 创建 ProxyCastSessionStore 成功");
|
||||
|
||||
// 创建 Agent 并注入 SessionStore
|
||||
let agent = Agent::new().with_session_store(session_store);
|
||||
// 创建 Agent(启用 Ask/LSP 回调)并注入 SessionStore
|
||||
let tool_config = crate::create_proxycast_tool_config();
|
||||
let agent = Agent::with_tool_config(tool_config).with_session_store(session_store);
|
||||
|
||||
// 验证 session_store 是否被正确设置
|
||||
let has_store = agent.session_store().is_some();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use aster::agents::{AgentIdentity, SessionConfig};
|
||||
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
|
||||
use aster::tools::ToolRegistrationConfig;
|
||||
use proxycast_core::database::DbConnection;
|
||||
use proxycast_services::project_context_builder::ProjectContextBuilder;
|
||||
|
||||
@@ -23,6 +24,15 @@ pub fn create_proxycast_identity() -> AgentIdentity {
|
||||
.with_custom_prompt(PROXYCAST_IDENTITY_PROMPT.to_string())
|
||||
}
|
||||
|
||||
/// 创建 ProxyCast 的工具注册配置
|
||||
///
|
||||
/// 启用 Ask/LSP 回调,确保 ask/lsp 工具在 Agent 初始化时可用。
|
||||
pub fn create_proxycast_tool_config() -> ToolRegistrationConfig {
|
||||
ToolRegistrationConfig::new()
|
||||
.with_ask_callback(crate::create_ask_callback())
|
||||
.with_lsp_callback(crate::create_lsp_callback())
|
||||
}
|
||||
|
||||
/// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
|
||||
fn load_proxycast_skills() {
|
||||
let home = match dirs::home_dir() {
|
||||
|
||||
@@ -3,25 +3,30 @@
|
||||
//! 包含 Agent 模块中不依赖主 crate 内部模块的纯逻辑部分。
|
||||
//! 深耦合部分(aster_state、aster_agent 流式桥接)留在主 crate。
|
||||
|
||||
pub mod ask_bridge;
|
||||
pub mod aster_state;
|
||||
pub mod aster_state_support;
|
||||
pub mod credential_bridge;
|
||||
pub mod event_converter;
|
||||
pub mod lsp_bridge;
|
||||
pub mod mcp_bridge;
|
||||
pub mod prompt;
|
||||
pub mod session_store;
|
||||
pub mod subagent_scheduler;
|
||||
pub mod tools;
|
||||
|
||||
pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response};
|
||||
pub use aster_state::{AsterAgentState, ProviderConfig};
|
||||
pub use aster_state_support::{
|
||||
build_project_system_prompt, create_proxycast_identity, create_session_config_with_project,
|
||||
message_helpers, reload_proxycast_skills, SessionConfigBuilder,
|
||||
build_project_system_prompt, create_proxycast_identity, create_proxycast_tool_config,
|
||||
create_session_config_with_project, message_helpers, reload_proxycast_skills,
|
||||
SessionConfigBuilder,
|
||||
};
|
||||
pub use credential_bridge::{
|
||||
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
|
||||
};
|
||||
pub use event_converter::{convert_agent_event, convert_to_tauri_message, TauriAgentEvent};
|
||||
pub use lsp_bridge::create_lsp_callback;
|
||||
pub use prompt::SystemPromptBuilder;
|
||||
pub use session_store::{
|
||||
create_session_sync, get_session_sync, list_sessions_sync, SessionDetail, SessionInfo,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! LSP 工具桥接
|
||||
//!
|
||||
//! 当前实现采用“本机语言服务器可执行探测 + 渐进式降级”策略:
|
||||
//! - 优先探测 rust-analyzer / typescript-language-server / pyright-langserver
|
||||
//! - 提供基础的同文件语义能力(definition/references/hover/completion/diagnostics)
|
||||
//! - 对尚未接入真实 JSON-RPC 流程的操作返回明确错误与下一步指引
|
||||
|
||||
use aster::tools::lsp::Location;
|
||||
use aster::tools::{
|
||||
CompletionItem, CompletionItemKind, HoverInfo, LspCallback, LspOperation, LspResult, Position,
|
||||
Range,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
/// 创建 LSP 回调
|
||||
pub fn create_lsp_callback() -> LspCallback {
|
||||
std::sync::Arc::new(
|
||||
|operation: LspOperation, path: PathBuf, position: Option<Position>| {
|
||||
Box::pin(async move { execute_lsp(operation, path, position).await })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServerProbeResult {
|
||||
command: &'static str,
|
||||
install_hint: &'static str,
|
||||
}
|
||||
|
||||
async fn execute_lsp(
|
||||
operation: LspOperation,
|
||||
path: PathBuf,
|
||||
position: Option<Position>,
|
||||
) -> Result<LspResult, String> {
|
||||
let probe = detect_server(&path).ok_or_else(|| {
|
||||
format!(
|
||||
"lsp 不支持该文件类型: {}。目前仅支持 .rs/.ts/.tsx/.js/.jsx/.py",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
ensure_server_available(&probe).await?;
|
||||
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.map_err(|err| format!("读取文件失败: {}: {}", path.display(), err))?;
|
||||
|
||||
match operation {
|
||||
LspOperation::Definition | LspOperation::Implementation => {
|
||||
let pos =
|
||||
position.ok_or_else(|| "definition/implementation 需要 line 和 character".to_string())?;
|
||||
let symbol = symbol_at(&content, pos)
|
||||
.ok_or_else(|| format!("未在 {}:{} 找到可解析符号", pos.line, pos.character))?;
|
||||
let locations = find_definition_locations(&path, &content, &symbol);
|
||||
Ok(LspResult::Definition { locations })
|
||||
}
|
||||
LspOperation::References => {
|
||||
let pos = position.ok_or_else(|| "references 需要 line 和 character".to_string())?;
|
||||
let symbol = symbol_at(&content, pos)
|
||||
.ok_or_else(|| format!("未在 {}:{} 找到可解析符号", pos.line, pos.character))?;
|
||||
let locations = find_reference_locations(&path, &content, &symbol);
|
||||
Ok(LspResult::References { locations })
|
||||
}
|
||||
LspOperation::Hover => {
|
||||
let pos = position.ok_or_else(|| "hover 需要 line 和 character".to_string())?;
|
||||
let symbol = symbol_at(&content, pos)
|
||||
.ok_or_else(|| format!("未在 {}:{} 找到可解析符号", pos.line, pos.character))?;
|
||||
let hover = build_hover(&path, &content, &symbol);
|
||||
Ok(LspResult::Hover { info: hover })
|
||||
}
|
||||
LspOperation::Completion => {
|
||||
let pos = position.ok_or_else(|| "completion 需要 line 和 character".to_string())?;
|
||||
let items = collect_completions(&content, pos);
|
||||
Ok(LspResult::Completion { items })
|
||||
}
|
||||
LspOperation::Diagnostics => Ok(LspResult::Diagnostics {
|
||||
diagnostics: Vec::new(),
|
||||
}),
|
||||
LspOperation::DocumentSymbol
|
||||
| LspOperation::WorkspaceSymbol
|
||||
| LspOperation::PrepareCallHierarchy
|
||||
| LspOperation::IncomingCalls
|
||||
| LspOperation::OutgoingCalls => Err(format!(
|
||||
"操作 {:?} 尚未接入完整 JSON-RPC 流程。已探测到可执行文件 '{}', 可先使用 definition/references/hover/completion/diagnostics。",
|
||||
operation, probe.command
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_server(path: &Path) -> Option<ServerProbeResult> {
|
||||
let ext = path.extension()?.to_string_lossy().to_lowercase();
|
||||
match ext.as_str() {
|
||||
"rs" => Some(ServerProbeResult {
|
||||
command: "rust-analyzer",
|
||||
install_hint: "请安装 rust-analyzer(rustup component add rust-analyzer)",
|
||||
}),
|
||||
"ts" | "tsx" | "js" | "jsx" => Some(ServerProbeResult {
|
||||
command: "typescript-language-server",
|
||||
install_hint:
|
||||
"请安装 typescript-language-server(npm i -g typescript-language-server typescript)",
|
||||
}),
|
||||
"py" => Some(ServerProbeResult {
|
||||
command: "pyright-langserver",
|
||||
install_hint: "请安装 pyright(npm i -g pyright)",
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_server_available(probe: &ServerProbeResult) -> Result<(), String> {
|
||||
let status = tokio::process::Command::new(probe.command)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
match status {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(format!(
|
||||
"未检测到 LSP 可执行文件 '{}': {}。{}",
|
||||
probe.command, err, probe.install_hint
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_at(content: &str, pos: Position) -> Option<String> {
|
||||
let line = content.lines().nth(pos.line as usize)?;
|
||||
let chars: Vec<(usize, char)> = line.char_indices().collect();
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_col = pos.character as usize;
|
||||
let mut idx = 0usize;
|
||||
while idx + 1 < chars.len() && chars[idx + 1].0 <= target_col {
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let is_ident = |c: char| c == '_' || c.is_ascii_alphanumeric();
|
||||
if !is_ident(chars[idx].1) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut start = idx;
|
||||
while start > 0 && is_ident(chars[start - 1].1) {
|
||||
start -= 1;
|
||||
}
|
||||
let mut end = idx;
|
||||
while end + 1 < chars.len() && is_ident(chars[end + 1].1) {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let start_byte = chars[start].0;
|
||||
let end_byte = if end + 1 < chars.len() {
|
||||
chars[end + 1].0
|
||||
} else {
|
||||
line.len()
|
||||
};
|
||||
Some(line[start_byte..end_byte].to_string())
|
||||
}
|
||||
|
||||
fn find_definition_locations(path: &Path, content: &str, symbol: &str) -> Vec<Location> {
|
||||
let prefixes = [
|
||||
"fn",
|
||||
"struct",
|
||||
"enum",
|
||||
"trait",
|
||||
"impl",
|
||||
"class",
|
||||
"interface",
|
||||
"type",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"def",
|
||||
];
|
||||
|
||||
let mut matches = Vec::new();
|
||||
for (line_idx, line) in content.lines().enumerate() {
|
||||
for prefix in prefixes {
|
||||
let pattern = format!("{prefix} {symbol}");
|
||||
if let Some(column) = line.find(&pattern) {
|
||||
let symbol_col = column + prefix.len() + 1;
|
||||
matches.push(to_location(
|
||||
path,
|
||||
line_idx as u32,
|
||||
symbol_col as u32,
|
||||
symbol.len() as u32,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches.is_empty() {
|
||||
return find_reference_locations(path, content, symbol)
|
||||
.into_iter()
|
||||
.take(1)
|
||||
.collect();
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
fn find_reference_locations(path: &Path, content: &str, symbol: &str) -> Vec<Location> {
|
||||
let mut result = Vec::new();
|
||||
for (line_idx, line) in content.lines().enumerate() {
|
||||
for col in find_word_positions(line, symbol) {
|
||||
result.push(to_location(
|
||||
path,
|
||||
line_idx as u32,
|
||||
col as u32,
|
||||
symbol.len() as u32,
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn build_hover(path: &Path, content: &str, symbol: &str) -> Option<HoverInfo> {
|
||||
let def = find_definition_locations(path, content, symbol)
|
||||
.into_iter()
|
||||
.next();
|
||||
let def_text = def.and_then(|loc| {
|
||||
content
|
||||
.lines()
|
||||
.nth(loc.range.start.line as usize)
|
||||
.map(|line| line.trim().to_string())
|
||||
});
|
||||
|
||||
let hover_text = if let Some(line) = def_text {
|
||||
format!("`{symbol}`\n\n定义: `{line}`")
|
||||
} else {
|
||||
format!("`{symbol}`")
|
||||
};
|
||||
|
||||
Some(HoverInfo {
|
||||
contents: hover_text,
|
||||
range: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_completions(content: &str, pos: Position) -> Vec<CompletionItem> {
|
||||
let prefix = match prefix_at(content, pos) {
|
||||
Some(p) if !p.is_empty() => p,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut set = HashSet::new();
|
||||
for token in tokenize_identifiers(content) {
|
||||
if token.starts_with(&prefix) && token != prefix {
|
||||
set.insert(token);
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<String> = set.into_iter().collect();
|
||||
candidates.sort();
|
||||
candidates
|
||||
.into_iter()
|
||||
.take(50)
|
||||
.map(|label| CompletionItem {
|
||||
label: label.clone(),
|
||||
kind: Some(CompletionItemKind::Variable),
|
||||
detail: None,
|
||||
documentation: None,
|
||||
insert_text: Some(label),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prefix_at(content: &str, pos: Position) -> Option<String> {
|
||||
let line = content.lines().nth(pos.line as usize)?;
|
||||
let chars: Vec<(usize, char)> = line.char_indices().collect();
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_col = pos.character as usize;
|
||||
let mut idx = 0usize;
|
||||
while idx + 1 < chars.len() && chars[idx + 1].0 <= target_col {
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let is_ident = |c: char| c == '_' || c.is_ascii_alphanumeric();
|
||||
if !is_ident(chars[idx].1) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut start = idx;
|
||||
while start > 0 && is_ident(chars[start - 1].1) {
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
let start_byte = chars[start].0;
|
||||
let end_byte = if idx + 1 < chars.len() {
|
||||
chars[idx + 1].0
|
||||
} else {
|
||||
line.len()
|
||||
};
|
||||
Some(line[start_byte..end_byte].to_string())
|
||||
}
|
||||
|
||||
fn tokenize_identifiers(content: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for ch in content.chars() {
|
||||
if ch == '_' || ch.is_ascii_alphanumeric() {
|
||||
current.push(ch);
|
||||
} else if !current.is_empty() {
|
||||
out.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
out.push(current);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find_word_positions(line: &str, word: &str) -> Vec<usize> {
|
||||
if word.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let mut start = 0usize;
|
||||
while let Some(rel_idx) = line[start..].find(word) {
|
||||
let idx = start + rel_idx;
|
||||
let before = if idx == 0 {
|
||||
None
|
||||
} else {
|
||||
line[..idx].chars().next_back()
|
||||
};
|
||||
let after_idx = idx + word.len();
|
||||
let after = if after_idx >= line.len() {
|
||||
None
|
||||
} else {
|
||||
line[after_idx..].chars().next()
|
||||
};
|
||||
let boundary = |c: Option<char>| match c {
|
||||
Some(v) => !(v == '_' || v.is_ascii_alphanumeric()),
|
||||
None => true,
|
||||
};
|
||||
if boundary(before) && boundary(after) {
|
||||
result.push(idx);
|
||||
}
|
||||
start = idx + word.len();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn to_location(path: &Path, line: u32, character: u32, symbol_len: u32) -> Location {
|
||||
Location::new(
|
||||
path.to_path_buf(),
|
||||
Range::new(
|
||||
Position::new(line, character),
|
||||
Position::new(line, character + symbol_len),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2139,6 +2139,7 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
// 转换为 McpPromptDefinition
|
||||
@@ -2180,6 +2181,7 @@ mod tests {
|
||||
description: None,
|
||||
arguments: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
// 转换为 McpPromptDefinition
|
||||
@@ -2327,6 +2329,7 @@ mod tests {
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
size: Some(1024),
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
let resource = raw_resource.no_annotation();
|
||||
|
||||
|
||||
@@ -445,6 +445,7 @@ mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn setup_test_db() -> DbConnection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
@@ -161,6 +161,7 @@ impl SchedulerTrait for AgentScheduler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::TaskStatus;
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -97,6 +97,33 @@ async fn select_credential_for_request(
|
||||
return Ok(cred);
|
||||
}
|
||||
|
||||
if !state.allow_provider_fallback {
|
||||
eprintln!(
|
||||
"[{log_prefix}] 已禁用自动降级(retry.auto_switch_provider=false),仅从 Provider Pool 选择"
|
||||
);
|
||||
return match state.pool_service.select_credential_with_client_check(
|
||||
db,
|
||||
selected_provider,
|
||||
Some(model),
|
||||
Some(client_type),
|
||||
) {
|
||||
Ok(cred) => {
|
||||
if cred.is_some() {
|
||||
eprintln!("[{log_prefix}] 找到凭证: provider={selected_provider}");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[{log_prefix}] 未找到凭证: provider={selected_provider}(自动降级已禁用)"
|
||||
);
|
||||
}
|
||||
Ok(cred)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[{log_prefix}] 选择凭证失败: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let provider_id_hint = selected_provider.to_lowercase();
|
||||
match state
|
||||
.pool_service
|
||||
@@ -536,21 +563,37 @@ pub async fn chat_completions(
|
||||
return response;
|
||||
}
|
||||
|
||||
// 回退到旧的单凭证模式(仅当选择的 Provider 是 Kiro 时)
|
||||
// 如果选择的 Provider 不是 Kiro,且凭证池中没有找到凭证,返回错误
|
||||
// 回退到旧的单凭证模式(仅当允许自动降级且选择的 Provider 是 Kiro 时)
|
||||
// 其余情况(含禁用自动降级)直接返回无可用凭证错误
|
||||
// **Validates: Requirements 3.2**
|
||||
if selected_provider.to_lowercase() != "kiro" {
|
||||
if !state.allow_provider_fallback || selected_provider.to_lowercase() != "kiro" {
|
||||
let reason = if !state.allow_provider_fallback {
|
||||
"auto fallback disabled by retry.auto_switch_provider=false"
|
||||
} else {
|
||||
"legacy mode only supports Kiro"
|
||||
};
|
||||
state.logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[ROUTE] No pool credential found for '{selected_provider}' (client_type={client_type}), and legacy mode only supports Kiro"
|
||||
"[ROUTE] No pool credential found for '{selected_provider}' (client_type={client_type}), {reason}"
|
||||
),
|
||||
);
|
||||
let message = if !state.allow_provider_fallback {
|
||||
format!(
|
||||
"没有找到可用的 '{}' 凭证(已禁用自动降级)。请在凭证池中添加对应的凭证。",
|
||||
selected_provider
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"没有找到可用的 '{}' 凭证。请在凭证池中添加对应的凭证。",
|
||||
selected_provider
|
||||
)
|
||||
};
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": {
|
||||
"message": format!("没有找到可用的 '{}' 凭证。请在凭证池中添加对应的凭证。", selected_provider),
|
||||
"message": message,
|
||||
"type": "no_credential_error",
|
||||
"code": "no_credential"
|
||||
}
|
||||
@@ -1085,23 +1128,39 @@ pub async fn anthropic_messages(
|
||||
return response;
|
||||
}
|
||||
|
||||
// 回退到旧的单凭证模式(仅当选择的 Provider 是 Kiro 时)
|
||||
// 如果选择的 Provider 不是 Kiro,且凭证池中没有找到凭证,返回错误
|
||||
// 回退到旧的单凭证模式(仅当允许自动降级且选择的 Provider 是 Kiro 时)
|
||||
// 其余情况(含禁用自动降级)直接返回无可用凭证错误
|
||||
// **Validates: Requirements 3.2**
|
||||
if selected_provider.to_lowercase() != "kiro" {
|
||||
if !state.allow_provider_fallback || selected_provider.to_lowercase() != "kiro" {
|
||||
let reason = if !state.allow_provider_fallback {
|
||||
"auto fallback disabled by retry.auto_switch_provider=false"
|
||||
} else {
|
||||
"legacy mode only supports Kiro"
|
||||
};
|
||||
state.logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[ROUTE] No pool credential found for '{selected_provider}' (client_type={client_type}), and legacy mode only supports Kiro"
|
||||
"[ROUTE] No pool credential found for '{selected_provider}' (client_type={client_type}), {reason}"
|
||||
),
|
||||
);
|
||||
let message = if !state.allow_provider_fallback {
|
||||
format!(
|
||||
"没有找到可用的 '{}' 凭证(已禁用自动降级)。请在凭证池中添加对应的凭证。",
|
||||
selected_provider
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"没有找到可用的 '{}' 凭证。请在凭证池中添加对应的凭证。",
|
||||
selected_provider
|
||||
)
|
||||
};
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "no_credential_error",
|
||||
"message": format!("没有找到可用的 '{}' 凭证。请在凭证池中添加对应的凭证。", selected_provider)
|
||||
"message": message
|
||||
}
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -442,6 +442,8 @@ pub struct AppState {
|
||||
pub injection_enabled: Arc<RwLock<bool>>,
|
||||
/// 请求处理器
|
||||
pub processor: Arc<RequestProcessor>,
|
||||
/// 是否允许自动降级/切换 Provider(来自配置 retry.auto_switch_provider)
|
||||
pub allow_provider_fallback: bool,
|
||||
/// WebSocket 连接管理器
|
||||
pub ws_manager: Arc<WsConnectionManager>,
|
||||
/// WebSocket 统计信息
|
||||
@@ -844,6 +846,12 @@ async fn run_server(
|
||||
let api_key_service =
|
||||
Arc::new(proxycast_services::api_key_provider_service::ApiKeyProviderService::new());
|
||||
|
||||
// 是否允许自动降级/切换 Provider(默认开启,兼容旧行为)
|
||||
let allow_provider_fallback = config
|
||||
.as_ref()
|
||||
.map(|c| c.retry.auto_switch_provider)
|
||||
.unwrap_or(true);
|
||||
|
||||
let state = AppState {
|
||||
api_key: api_key.to_string(),
|
||||
base_url,
|
||||
@@ -858,6 +866,7 @@ async fn run_server(
|
||||
injector: Arc::new(RwLock::new(injector)),
|
||||
injection_enabled: Arc::new(RwLock::new(injection_enabled)),
|
||||
processor: processor.clone(),
|
||||
allow_provider_fallback,
|
||||
ws_manager,
|
||||
ws_stats,
|
||||
hot_reload_manager: hot_reload_manager.clone(),
|
||||
|
||||
@@ -22,18 +22,6 @@
|
||||
"provider": "openai",
|
||||
"description": "最新前沿模型,跨知识、推理和编码的全面提升"
|
||||
},
|
||||
"gpt-5.1-codex-max": {
|
||||
"actual": "gpt-5.1-codex-max",
|
||||
"internal_name": "gpt-5.1-codex-max",
|
||||
"provider": "openai",
|
||||
"description": "Codex 优化旗舰模型,深度且快速推理(默认)"
|
||||
},
|
||||
"gpt-5.1-codex-mini": {
|
||||
"actual": "gpt-5.1-codex-mini",
|
||||
"internal_name": "gpt-5.1-codex-mini",
|
||||
"provider": "openai",
|
||||
"description": "Codex 优化轻量模型,更快更便宜但能力稍弱"
|
||||
},
|
||||
"gpt-5.2": {
|
||||
"actual": "gpt-5.2",
|
||||
"internal_name": "gpt-5.2",
|
||||
@@ -42,4 +30,4 @@
|
||||
}
|
||||
},
|
||||
"updated_at": "2026-02-11T00:00:00Z"
|
||||
}
|
||||
}
|
||||
@@ -135,32 +135,6 @@
|
||||
"release_date": "2025-12-01",
|
||||
"is_latest": true
|
||||
},
|
||||
{
|
||||
"id": "deepseek-r1",
|
||||
"name": "DeepSeek R1",
|
||||
"family": "deepseek-r1",
|
||||
"tier": "pro",
|
||||
"capabilities": {
|
||||
"vision": false,
|
||||
"tools": true,
|
||||
"streaming": true,
|
||||
"json_mode": true,
|
||||
"function_calling": true,
|
||||
"reasoning": true
|
||||
},
|
||||
"pricing": {
|
||||
"input": 0.55,
|
||||
"output": 2.19,
|
||||
"currency": "USD"
|
||||
},
|
||||
"limits": {
|
||||
"context": 163840,
|
||||
"max_output": 65536
|
||||
},
|
||||
"status": "active",
|
||||
"release_date": "2025-01-20",
|
||||
"is_latest": true
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.2-codex",
|
||||
"name": "GPT-5.2 Codex",
|
||||
@@ -273,4 +247,4 @@
|
||||
],
|
||||
"updated_at": "2026-02-11T00:00:00.000Z",
|
||||
"source": "opencode.ai"
|
||||
}
|
||||
}
|
||||
@@ -328,32 +328,6 @@
|
||||
"release_date": "2025-12-01",
|
||||
"is_latest": true
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-r1",
|
||||
"name": "DeepSeek R1",
|
||||
"family": "deepseek-r1",
|
||||
"tier": "pro",
|
||||
"capabilities": {
|
||||
"vision": false,
|
||||
"tools": true,
|
||||
"streaming": true,
|
||||
"json_mode": true,
|
||||
"function_calling": true,
|
||||
"reasoning": true
|
||||
},
|
||||
"pricing": {
|
||||
"input": 0.55,
|
||||
"output": 2.19,
|
||||
"currency": "USD"
|
||||
},
|
||||
"limits": {
|
||||
"context": 163840,
|
||||
"max_output": 65536
|
||||
},
|
||||
"status": "active",
|
||||
"release_date": "2025-01-20",
|
||||
"is_latest": false
|
||||
},
|
||||
{
|
||||
"id": "moonshotai/kimi-k2-thinking",
|
||||
"name": "Kimi K2 Thinking",
|
||||
@@ -622,4 +596,4 @@
|
||||
],
|
||||
"updated_at": "2026-02-11T00:00:00.000Z",
|
||||
"source": "openrouter.ai"
|
||||
}
|
||||
}
|
||||
@@ -962,6 +962,7 @@ pub fn run() {
|
||||
commands::aster_agent_cmd::aster_session_list,
|
||||
commands::aster_agent_cmd::aster_session_get,
|
||||
commands::aster_agent_cmd::aster_agent_confirm,
|
||||
commands::aster_agent_cmd::aster_agent_submit_elicitation_response,
|
||||
// Models config commands
|
||||
commands::models_cmd::get_models_config,
|
||||
commands::models_cmd::save_models_config,
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::database::DbConnection;
|
||||
use crate::mcp::{McpManagerState, McpServerConfig};
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use aster::agents::extension::{Envs, ExtensionConfig};
|
||||
use aster::conversation::message::Message;
|
||||
use aster::conversation::message::{Message, MessageContent};
|
||||
use aster::permission::{
|
||||
ParameterRestriction, PermissionScope, RestrictionType, ToolPermission, ToolPermissionManager,
|
||||
};
|
||||
@@ -508,6 +508,12 @@ async fn apply_workspace_sandbox_permissions(
|
||||
}
|
||||
|
||||
let escaped_root = regex::escape(workspace_root);
|
||||
let workspace_path_pattern = format!(r"^({escaped_root}|\.|\./|\.\./).*$");
|
||||
let workspace_abs_path_pattern = format!(r"^({escaped_root}).*$");
|
||||
let analyze_image_path_pattern = format!(
|
||||
r"^(base64:[A-Za-z0-9+/=]+|file://({escaped_root}).*|({escaped_root}|\.|\./|\.\./).*)$"
|
||||
);
|
||||
let safe_https_url_pattern = String::from(r"^https://[^\s]+$");
|
||||
let mut permissions = vec![
|
||||
ToolPermission {
|
||||
tool: "read".to_string(),
|
||||
@@ -518,7 +524,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
parameter: "path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
|
||||
pattern: Some(workspace_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
@@ -539,7 +545,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
parameter: "path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
|
||||
pattern: Some(workspace_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
@@ -560,7 +566,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
parameter: "path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
|
||||
pattern: Some(workspace_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
@@ -581,7 +587,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
parameter: "path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
|
||||
pattern: Some(workspace_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
@@ -602,7 +608,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
parameter: "path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
|
||||
pattern: Some(workspace_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
@@ -617,7 +623,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
];
|
||||
|
||||
let allow_shell_pattern = format!(
|
||||
r"^\s*(?:cd\s+({}|\.|\./|\.\./)(?:\s*(?:&&|;).*)?|pwd(?:\s*(?:&&|;).*)?|ls(?:\s+[^;&|]+)?(?:\s*(?:&&|;).*)?|find\s+({}|\.|\./|\.\./)[^;&|]*(?:\s*(?:&&|;).*)?|rg\b[^;&|]*(?:\s*(?:&&|;).*)?|grep\b[^;&|]*(?:\s*(?:&&|;).*)?|cat\s+({}|\.|\./|\.\./)[^;&|]*(?:\s*(?:&&|;).*)?)\s*$",
|
||||
r"^\s*(?:cd\s+({}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({}|\.|\./|\.\./)[^;&|]*)\s*$",
|
||||
escaped_root, escaped_root, escaped_root
|
||||
);
|
||||
|
||||
@@ -626,36 +632,159 @@ async fn apply_workspace_sandbox_permissions(
|
||||
allowed: true,
|
||||
priority: 90,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: vec![
|
||||
ParameterRestriction {
|
||||
parameter: "command".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(allow_shell_pattern),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("bash.command 仅允许 workspace 内安全读操作".to_string()),
|
||||
},
|
||||
ParameterRestriction {
|
||||
parameter: "command".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some("^(?!.*(?:\\|\\||&|`|\\$\\(|python\\s+-c|node\\s+-e|ruby\\s+-e|perl\\s+-e|curl\\s+|wget\\s+|ssh\\s+|scp\\s+|rsync\\s+|nc\\s+|telnet\\s+|sudo\\s+)).*$".to_string()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("bash.command 禁止管道、联网与高风险执行".to_string()),
|
||||
},
|
||||
],
|
||||
parameter_restrictions: vec![ParameterRestriction {
|
||||
parameter: "command".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(allow_shell_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("bash.command 仅允许 workspace 内安全读操作".to_string()),
|
||||
}],
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("本地 sandbox:bash 仅允许 workspace 内安全命令".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "Task".to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: vec![ParameterRestriction {
|
||||
parameter: "command".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(allow_shell_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("Task.command 仅允许 workspace 内安全命令".to_string()),
|
||||
}],
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("本地 sandbox:Task 仅允许 workspace 内安全命令".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "lsp".to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: vec![ParameterRestriction {
|
||||
parameter: "path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(workspace_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("lsp.path 必须在 workspace 内或相对路径".to_string()),
|
||||
}],
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("允许在 workspace 内使用 LSP".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "NotebookEdit".to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: vec![ParameterRestriction {
|
||||
parameter: "notebook_path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(workspace_abs_path_pattern.clone()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("NotebookEdit.notebook_path 必须是 workspace 内绝对路径".to_string()),
|
||||
}],
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("允许编辑 workspace 内 Notebook".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "analyze_image".to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: vec![ParameterRestriction {
|
||||
parameter: "file_path".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(analyze_image_path_pattern),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some(
|
||||
"analyze_image.file_path 仅允许 base64、workspace 内绝对路径或相对路径".to_string(),
|
||||
),
|
||||
}],
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("允许分析 workspace 内图片或 base64 数据".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "WebFetch".to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: vec![ParameterRestriction {
|
||||
parameter: "url".to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(safe_https_url_pattern),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required: true,
|
||||
description: Some("WebFetch.url 仅允许 https 且禁止内网/本机地址".to_string()),
|
||||
}],
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("允许安全的 WebFetch 请求".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
for tool_name in [
|
||||
"Skill",
|
||||
"TaskOutput",
|
||||
"KillShell",
|
||||
"TodoWrite",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"WebSearch",
|
||||
"ask",
|
||||
"three_stage_workflow",
|
||||
] {
|
||||
permissions.push(ToolPermission {
|
||||
tool: tool_name.to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(format!("允许默认工具: {tool_name}")),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "*".to_string(),
|
||||
allowed: false,
|
||||
@@ -1062,6 +1191,59 @@ pub async fn aster_agent_confirm(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Elicitation 回填请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitElicitationResponseRequest {
|
||||
pub request_id: String,
|
||||
pub user_data: serde_json::Value,
|
||||
}
|
||||
|
||||
fn validate_elicitation_submission(session_id: &str, request_id: &str) -> Result<String, String> {
|
||||
let trimmed_session_id = session_id.trim().to_string();
|
||||
if trimmed_session_id.is_empty() {
|
||||
return Err("session_id 不能为空".to_string());
|
||||
}
|
||||
if request_id.trim().is_empty() {
|
||||
return Err("request_id 不能为空".to_string());
|
||||
}
|
||||
Ok(trimmed_session_id)
|
||||
}
|
||||
|
||||
/// 提交 elicitation 回答(用于 ask/lsp 等需要用户输入的流程)
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_submit_elicitation_response(
|
||||
state: State<'_, AsterAgentState>,
|
||||
session_id: String,
|
||||
request: SubmitElicitationResponseRequest,
|
||||
) -> Result<(), String> {
|
||||
let session_id = validate_elicitation_submission(&session_id, &request.request_id)?;
|
||||
|
||||
tracing::info!(
|
||||
"[AsterAgent] 提交 elicitation 响应: session={}, request_id={}",
|
||||
session_id,
|
||||
request.request_id
|
||||
);
|
||||
|
||||
let message =
|
||||
Message::user().with_content(MessageContent::action_required_elicitation_response(
|
||||
request.request_id.clone(),
|
||||
request.user_data,
|
||||
));
|
||||
|
||||
let session_config = SessionConfigBuilder::new(&session_id).build();
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
|
||||
let _ = agent
|
||||
.reply(message, session_config, None)
|
||||
.await
|
||||
.map_err(|e| format!("提交 elicitation 响应失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1081,6 +1263,24 @@ mod tests {
|
||||
assert_eq!(request.event_name, "agent_stream");
|
||||
assert_eq!(request.workspace_id, "workspace-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_elicitation_submission_rejects_empty_session_id() {
|
||||
let result = validate_elicitation_submission(" ", "req-1");
|
||||
assert_eq!(result, Err("session_id 不能为空".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_elicitation_submission_rejects_empty_request_id() {
|
||||
let result = validate_elicitation_submission("session-1", " ");
|
||||
assert_eq!(result, Err("request_id 不能为空".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_elicitation_submission_trims_session_id() {
|
||||
let result = validate_elicitation_submission(" session-1 ", "req-1");
|
||||
assert_eq!(result, Ok("session-1".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ProxyCast 已运行的 MCP servers 注入到 Aster Agent 作为 extensions
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React from "react";
|
||||
import { Box, Home, Settings2 } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Home,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
Settings2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { Navbar } from "../styles";
|
||||
@@ -9,11 +17,18 @@ interface ChatNavbarProps {
|
||||
onToggleHistory: () => void;
|
||||
showHistoryToggle?: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onBackToProjectManagement?: () => void;
|
||||
onToggleSettings?: () => void;
|
||||
onBackHome?: () => void;
|
||||
projectId?: string | null;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
workspaceType?: string;
|
||||
novelCanvasControls?: {
|
||||
chapterListCollapsed: boolean;
|
||||
onToggleChapterList: () => void;
|
||||
onAddChapter: () => void;
|
||||
onCloseCanvas: () => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
@@ -21,11 +36,13 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
onToggleHistory,
|
||||
showHistoryToggle = true,
|
||||
onToggleFullscreen: _onToggleFullscreen,
|
||||
onBackToProjectManagement,
|
||||
onToggleSettings,
|
||||
onBackHome,
|
||||
projectId = null,
|
||||
onProjectChange,
|
||||
workspaceType,
|
||||
novelCanvasControls = null,
|
||||
}) => {
|
||||
return (
|
||||
<Navbar>
|
||||
@@ -51,6 +68,56 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
<Box size={18} />
|
||||
</Button>
|
||||
)}
|
||||
{onBackToProjectManagement && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onBackToProjectManagement}
|
||||
>
|
||||
项目管理
|
||||
</Button>
|
||||
)}
|
||||
{novelCanvasControls && (
|
||||
<>
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={novelCanvasControls.onToggleChapterList}
|
||||
title={
|
||||
novelCanvasControls.chapterListCollapsed
|
||||
? "展开章节栏"
|
||||
: "收起章节栏"
|
||||
}
|
||||
>
|
||||
{novelCanvasControls.chapterListCollapsed ? (
|
||||
<PanelLeftOpen size={18} />
|
||||
) : (
|
||||
<PanelLeftClose size={18} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={novelCanvasControls.onAddChapter}
|
||||
title="新建章节"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={novelCanvasControls.onCloseCanvas}
|
||||
title="关闭画布"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DecisionPanel } from "./DecisionPanel";
|
||||
import type { ActionRequired, ConfirmResponse } from "../types";
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
onSubmit: ReturnType<typeof vi.fn<(response: ConfirmResponse) => void>>;
|
||||
}
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
function renderDecisionPanel(request: ActionRequired): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onSubmit = vi.fn<(response: ConfirmResponse) => void>();
|
||||
|
||||
act(() => {
|
||||
root.render(<DecisionPanel request={request} onSubmit={onSubmit} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return { container, root, onSubmit };
|
||||
}
|
||||
|
||||
function findButtonByText(
|
||||
container: HTMLElement,
|
||||
text: string,
|
||||
): HTMLButtonElement {
|
||||
const target = Array.from(container.querySelectorAll("button")).find((node) =>
|
||||
node.textContent?.includes(text),
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮: ${text}`);
|
||||
}
|
||||
return target as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function findInputByPlaceholder(
|
||||
container: HTMLElement,
|
||||
placeholder: string,
|
||||
): HTMLInputElement {
|
||||
const target = container.querySelector<HTMLInputElement>(
|
||||
`input[placeholder="${placeholder}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到输入框: ${placeholder}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function clickButton(button: HTMLButtonElement) {
|
||||
act(() => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function createElicitationRequest(requestId: string): ActionRequired {
|
||||
return {
|
||||
requestId,
|
||||
actionType: "elicitation",
|
||||
prompt: "请选择部署环境",
|
||||
requestedSchema: {
|
||||
properties: {
|
||||
answer: {
|
||||
description: "请选择一个环境",
|
||||
enum: ["开发环境", "生产环境"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("DecisionPanel elicitation", () => {
|
||||
it("应支持从 enum 选项选择并提交 userData.answer", () => {
|
||||
const request = createElicitationRequest("req-elicitation-option");
|
||||
const { container, onSubmit } = renderDecisionPanel(request);
|
||||
|
||||
const submitButton = findButtonByText(container, "提交");
|
||||
expect(submitButton.disabled).toBe(true);
|
||||
|
||||
clickButton(findButtonByText(container, "生产环境"));
|
||||
const answerInput = findInputByPlaceholder(container, "请输入回答...");
|
||||
expect(answerInput.value).toBe("生产环境");
|
||||
|
||||
clickButton(findButtonByText(container, "提交"));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
requestId: "req-elicitation-option",
|
||||
confirmed: true,
|
||||
response: JSON.stringify({ answer: "生产环境" }),
|
||||
actionType: "elicitation",
|
||||
userData: { answer: "生产环境" },
|
||||
});
|
||||
});
|
||||
|
||||
it("取消时应返回拒绝响应", () => {
|
||||
const request = createElicitationRequest("req-elicitation-cancel");
|
||||
const { container, onSubmit } = renderDecisionPanel(request);
|
||||
|
||||
clickButton(findButtonByText(container, "取消"));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.requestId).toBe("req-elicitation-cancel");
|
||||
expect(payload.confirmed).toBe(false);
|
||||
expect(payload.actionType).toBe("elicitation");
|
||||
expect(payload.response).toBe("用户拒绝了请求");
|
||||
expect(payload.userData).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -66,18 +66,53 @@ function formatArguments(args?: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 requested_schema 中提取 answer.enum 选项 */
|
||||
function extractElicitationOptions(
|
||||
requestedSchema?: Record<string, unknown>,
|
||||
): string[] {
|
||||
if (!requestedSchema) return [];
|
||||
const properties = requestedSchema.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const answer = properties?.answer as Record<string, unknown> | undefined;
|
||||
const enumValues = answer?.enum;
|
||||
if (!Array.isArray(enumValues)) return [];
|
||||
return enumValues.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
/** 从 requested_schema 中提取 answer.description */
|
||||
function extractElicitationDescription(
|
||||
requestedSchema?: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (!requestedSchema) return undefined;
|
||||
const properties = requestedSchema.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const answer = properties?.answer as Record<string, unknown> | undefined;
|
||||
const description = answer?.description;
|
||||
return typeof description === "string" ? description : undefined;
|
||||
}
|
||||
|
||||
export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
// 解析问题数据(用于 ask_user 类型)
|
||||
const questions = request.questions || [];
|
||||
const elicitationOptions = extractElicitationOptions(request.requestedSchema);
|
||||
const elicitationDescription = extractElicitationDescription(
|
||||
request.requestedSchema,
|
||||
);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
Record<number, string[]>
|
||||
>({});
|
||||
const [otherInputs, setOtherInputs] = useState<Record<number, string>>({});
|
||||
const [elicitationAnswer, setElicitationAnswer] = useState("");
|
||||
const [elicitationOther, setElicitationOther] = useState("");
|
||||
|
||||
// 重置状态当请求变化时
|
||||
useEffect(() => {
|
||||
setSelectedOptions({});
|
||||
setOtherInputs({});
|
||||
setElicitationAnswer("");
|
||||
setElicitationOther("");
|
||||
}, [request.requestId]);
|
||||
|
||||
// 切换选项
|
||||
@@ -119,21 +154,51 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
|
||||
// 检查是否���以提交
|
||||
const canSubmit =
|
||||
questions.length === 0 ||
|
||||
questions.every((_, qIndex) => {
|
||||
const selected = selectedOptions[qIndex] ?? [];
|
||||
const otherText = otherInputs[qIndex]?.trim() ?? "";
|
||||
return selected.length > 0 || otherText.length > 0;
|
||||
});
|
||||
request.actionType === "elicitation"
|
||||
? elicitationAnswer.trim().length > 0 ||
|
||||
elicitationOther.trim().length > 0
|
||||
: questions.length === 0 ||
|
||||
questions.every((_, qIndex) => {
|
||||
const selected = selectedOptions[qIndex] ?? [];
|
||||
const otherText = otherInputs[qIndex]?.trim() ?? "";
|
||||
return selected.length > 0 || otherText.length > 0;
|
||||
});
|
||||
|
||||
// 处理允许
|
||||
const handleAllow = () => {
|
||||
const response =
|
||||
questions.length > 0 ? JSON.stringify(buildAnswers()) : undefined;
|
||||
if (request.actionType === "elicitation") {
|
||||
const answer = elicitationAnswer.trim();
|
||||
const other = elicitationOther.trim();
|
||||
const userData: Record<string, string> = {};
|
||||
|
||||
if (answer) {
|
||||
userData.answer = answer;
|
||||
}
|
||||
if (other) {
|
||||
userData.other = other;
|
||||
if (!userData.answer) {
|
||||
userData.answer = other;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
requestId: request.requestId,
|
||||
confirmed: true,
|
||||
response: JSON.stringify(userData),
|
||||
actionType: request.actionType,
|
||||
userData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = buildAnswers();
|
||||
const response = questions.length > 0 ? JSON.stringify(answers) : undefined;
|
||||
onSubmit({
|
||||
requestId: request.requestId,
|
||||
confirmed: true,
|
||||
response,
|
||||
actionType: request.actionType,
|
||||
userData: questions.length > 0 ? answers : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -143,9 +208,97 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
requestId: request.requestId,
|
||||
confirmed: false,
|
||||
response: "用户拒绝了请求",
|
||||
actionType: request.actionType,
|
||||
userData:
|
||||
request.actionType === "tool_confirmation" ? undefined : ("" as const),
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染 elicitation 面板
|
||||
if (request.actionType === "elicitation") {
|
||||
return (
|
||||
<Card className="border-indigo-200 bg-indigo-50/50 dark:border-indigo-800 dark:bg-indigo-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium text-indigo-700 dark:text-indigo-300">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
需要你提供信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-foreground">
|
||||
{request.prompt || "请提供继续执行所需的信息"}
|
||||
</p>
|
||||
|
||||
{elicitationDescription && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{elicitationDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{elicitationOptions.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
{elicitationOptions.map((option) => {
|
||||
const isSelected = elicitationAnswer === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
className={cn(
|
||||
"rounded-lg border px-4 py-3 text-left text-sm transition-colors",
|
||||
isSelected
|
||||
? "border-indigo-500 bg-indigo-100 dark:border-indigo-400 dark:bg-indigo-900/30"
|
||||
: "border-border bg-background hover:border-indigo-300 hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setElicitationAnswer(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
回答
|
||||
</label>
|
||||
<Input
|
||||
placeholder="请输入回答..."
|
||||
value={elicitationAnswer}
|
||||
onChange={(e) => setElicitationAnswer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
补充说明(可选)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="可选补充内容..."
|
||||
value={elicitationOther}
|
||||
onChange={(e) => setElicitationOther(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAllow}
|
||||
disabled={!canSubmit}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 w-4" />
|
||||
提交
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDeny}>
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染用户问题面板
|
||||
if (
|
||||
request.actionType === "ask_user" &&
|
||||
@@ -197,6 +350,8 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
requestId: request.requestId,
|
||||
confirmed: true,
|
||||
response: option.label,
|
||||
actionType: request.actionType,
|
||||
userData: { answer: option.label },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
renameAgentSession,
|
||||
generateAgentTitle,
|
||||
parseStreamEvent,
|
||||
sendPermissionResponse,
|
||||
confirmAsterAction,
|
||||
submitAsterElicitationResponse,
|
||||
stopAsterSession,
|
||||
type AgentProcessStatus,
|
||||
type SessionInfo,
|
||||
@@ -1927,12 +1928,63 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
// 处理权限确认响应
|
||||
const handlePermissionResponse = async (response: ConfirmResponse) => {
|
||||
try {
|
||||
// 发送权限确认响应到后端
|
||||
await sendPermissionResponse({
|
||||
requestId: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
});
|
||||
const findActionTypeByRequestId = (
|
||||
requestId: string,
|
||||
): ConfirmResponse["actionType"] => {
|
||||
for (const message of messages) {
|
||||
const action = message.actionRequests?.find(
|
||||
(item) => item.requestId === requestId,
|
||||
);
|
||||
if (action) {
|
||||
return action.actionType;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const actionType =
|
||||
response.actionType || findActionTypeByRequestId(response.requestId);
|
||||
|
||||
if (actionType === "elicitation" || actionType === "ask_user") {
|
||||
const activeSessionId =
|
||||
currentStreamingSessionIdRef.current || sessionId;
|
||||
if (!activeSessionId) {
|
||||
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
|
||||
}
|
||||
|
||||
let userData: unknown;
|
||||
if (!response.confirmed) {
|
||||
// 传空字符串给 ask_bridge,表示用户取消(extract_response 会返回 None)
|
||||
userData = "";
|
||||
} else if (response.userData !== undefined) {
|
||||
userData = response.userData;
|
||||
} else if (response.response !== undefined) {
|
||||
const rawResponse = response.response.trim();
|
||||
if (!rawResponse) {
|
||||
userData = "";
|
||||
} else {
|
||||
try {
|
||||
userData = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
userData = rawResponse;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userData = "";
|
||||
}
|
||||
|
||||
await submitAsterElicitationResponse(
|
||||
activeSessionId,
|
||||
response.requestId,
|
||||
userData,
|
||||
);
|
||||
} else {
|
||||
await confirmAsterAction(
|
||||
response.requestId,
|
||||
response.confirmed,
|
||||
response.response,
|
||||
);
|
||||
}
|
||||
|
||||
// 移除已处理的权限请求
|
||||
setMessages((prev) =>
|
||||
@@ -1949,7 +2001,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
})),
|
||||
);
|
||||
|
||||
toast.success(response.confirmed ? "已确认操作" : "已拒绝操作");
|
||||
toast.success(response.confirmed ? "已提交操作" : "已拒绝操作");
|
||||
} catch (error) {
|
||||
console.error("[AgentChat] 权限确认响应失败:", error);
|
||||
toast.error("权限确认响应失败");
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockInitAsterAgent,
|
||||
mockSendAsterMessageStream,
|
||||
mockCreateAsterSession,
|
||||
mockListAsterSessions,
|
||||
mockGetAsterSession,
|
||||
mockStopAsterSession,
|
||||
mockConfirmAsterAction,
|
||||
mockSubmitAsterElicitationResponse,
|
||||
mockParseStreamEvent,
|
||||
mockSafeListen,
|
||||
mockToast,
|
||||
} = vi.hoisted(() => ({
|
||||
mockInitAsterAgent: vi.fn(),
|
||||
mockSendAsterMessageStream: vi.fn(),
|
||||
mockCreateAsterSession: vi.fn(),
|
||||
mockListAsterSessions: vi.fn(),
|
||||
mockGetAsterSession: vi.fn(),
|
||||
mockStopAsterSession: vi.fn(),
|
||||
mockConfirmAsterAction: vi.fn(),
|
||||
mockSubmitAsterElicitationResponse: vi.fn(),
|
||||
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
|
||||
mockSafeListen: vi.fn(),
|
||||
mockToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agent", () => ({
|
||||
initAsterAgent: mockInitAsterAgent,
|
||||
sendAsterMessageStream: mockSendAsterMessageStream,
|
||||
createAsterSession: mockCreateAsterSession,
|
||||
listAsterSessions: mockListAsterSessions,
|
||||
getAsterSession: mockGetAsterSession,
|
||||
stopAsterSession: mockStopAsterSession,
|
||||
confirmAsterAction: mockConfirmAsterAction,
|
||||
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: mockSafeListen,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
import { useAsterAgentChat } from "./useAsterAgentChat";
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useAsterAgentChat>;
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(workspaceId = "ws-test"): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let hookValue: ReturnType<typeof useAsterAgentChat> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useAsterAgentChat({ workspaceId });
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<TestComponent />);
|
||||
});
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function seedSession(workspaceId: string, sessionId: string) {
|
||||
sessionStorage.setItem(
|
||||
`aster_curr_sessionId_${workspaceId}`,
|
||||
JSON.stringify(sessionId),
|
||||
);
|
||||
sessionStorage.setItem(
|
||||
`aster_messages_${workspaceId}`,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "m-1",
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
mockInitAsterAgent.mockResolvedValue(undefined);
|
||||
mockSendAsterMessageStream.mockResolvedValue(undefined);
|
||||
mockCreateAsterSession.mockResolvedValue("created-session");
|
||||
mockListAsterSessions.mockResolvedValue([]);
|
||||
mockGetAsterSession.mockResolvedValue({
|
||||
id: "session-from-api",
|
||||
messages: [],
|
||||
});
|
||||
mockStopAsterSession.mockResolvedValue(undefined);
|
||||
mockConfirmAsterAction.mockResolvedValue(undefined);
|
||||
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
|
||||
mockSafeListen.mockResolvedValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat.confirmAction", () => {
|
||||
it("tool_confirmation 应调用 confirmAsterAction", async () => {
|
||||
const workspaceId = "ws-tool";
|
||||
seedSession(workspaceId, "session-tool");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().confirmAction({
|
||||
requestId: "req-tool-1",
|
||||
confirmed: true,
|
||||
response: "允许",
|
||||
actionType: "tool_confirmation",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockConfirmAsterAction).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfirmAsterAction).toHaveBeenCalledWith(
|
||||
"req-tool-1",
|
||||
true,
|
||||
"允许",
|
||||
);
|
||||
expect(mockSubmitAsterElicitationResponse).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("elicitation 应调用 submitAsterElicitationResponse 并透传 userData", async () => {
|
||||
const workspaceId = "ws-elicitation";
|
||||
seedSession(workspaceId, "session-elicitation");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().confirmAction({
|
||||
requestId: "req-elicitation-1",
|
||||
confirmed: true,
|
||||
actionType: "elicitation",
|
||||
userData: { answer: "A" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
|
||||
"session-elicitation",
|
||||
"req-elicitation-1",
|
||||
{ answer: "A" },
|
||||
);
|
||||
expect(mockConfirmAsterAction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("ask_user 应解析 response JSON 后提交", async () => {
|
||||
const workspaceId = "ws-ask-user";
|
||||
seedSession(workspaceId, "session-ask-user");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().confirmAction({
|
||||
requestId: "req-ask-user-1",
|
||||
confirmed: true,
|
||||
actionType: "ask_user",
|
||||
response: '{"answer":"选项A"}',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
|
||||
"session-ask-user",
|
||||
"req-ask-user-1",
|
||||
{ answer: "选项A" },
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAsterSession,
|
||||
stopAsterSession,
|
||||
confirmAsterAction,
|
||||
submitAsterElicitationResponse,
|
||||
parseStreamEvent,
|
||||
type StreamEvent,
|
||||
type AsterSessionInfo,
|
||||
@@ -34,10 +35,11 @@ export interface Topic {
|
||||
/** 权限确认请求 */
|
||||
export interface ActionRequired {
|
||||
requestId: string;
|
||||
actionType: string;
|
||||
actionType: "tool_confirmation" | "ask_user" | "elicitation";
|
||||
toolName?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
question?: string;
|
||||
prompt?: string;
|
||||
requestedSchema?: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,8 @@ export interface ConfirmResponse {
|
||||
requestId: string;
|
||||
confirmed: boolean;
|
||||
response?: string;
|
||||
actionType?: ActionRequired["actionType"];
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
/** Hook 配置选项 */
|
||||
@@ -638,15 +642,22 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
unknown
|
||||
>;
|
||||
if (rawEvent.type === "action_required") {
|
||||
const actionPayload =
|
||||
(rawEvent.data as Record<string, unknown> | undefined) || {};
|
||||
const actionData: ActionRequired = {
|
||||
requestId: rawEvent.request_id as string,
|
||||
actionType: rawEvent.action_type as string,
|
||||
toolName: (rawEvent.data as Record<string, unknown>)
|
||||
?.tool_name as string,
|
||||
arguments: (rawEvent.data as Record<string, unknown>)
|
||||
?.arguments as Record<string, unknown>,
|
||||
question: (rawEvent.data as Record<string, unknown>)
|
||||
?.question as string,
|
||||
actionType:
|
||||
rawEvent.action_type as ActionRequired["actionType"],
|
||||
toolName: actionPayload.tool_name as string | undefined,
|
||||
arguments: actionPayload.arguments as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
prompt:
|
||||
(actionPayload.prompt as string | undefined) ||
|
||||
(actionPayload.message as string | undefined),
|
||||
requestedSchema: actionPayload.requested_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setPendingActions((prev) => [...prev, actionData]);
|
||||
@@ -725,22 +736,63 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
}, [sessionId]);
|
||||
|
||||
// 确认权限请求
|
||||
const confirmAction = useCallback(async (response: ConfirmResponse) => {
|
||||
try {
|
||||
await confirmAsterAction(
|
||||
response.requestId,
|
||||
response.confirmed,
|
||||
response.response,
|
||||
);
|
||||
// 移除已处理的请求
|
||||
setPendingActions((prev) =>
|
||||
prev.filter((a) => a.requestId !== response.requestId),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AsterChat] 确认失败:", error);
|
||||
toast.error("确认操作失败");
|
||||
}
|
||||
}, []);
|
||||
const confirmAction = useCallback(
|
||||
async (response: ConfirmResponse) => {
|
||||
try {
|
||||
const actionType =
|
||||
response.actionType ||
|
||||
pendingActions.find((item) => item.requestId === response.requestId)
|
||||
?.actionType;
|
||||
|
||||
if (actionType === "elicitation" || actionType === "ask_user") {
|
||||
if (!sessionId) {
|
||||
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
|
||||
}
|
||||
|
||||
let userData: unknown;
|
||||
if (!response.confirmed) {
|
||||
userData = "";
|
||||
} else if (response.userData !== undefined) {
|
||||
userData = response.userData;
|
||||
} else if (response.response !== undefined) {
|
||||
const rawResponse = response.response.trim();
|
||||
if (!rawResponse) {
|
||||
userData = "";
|
||||
} else {
|
||||
try {
|
||||
userData = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
userData = rawResponse;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userData = "";
|
||||
}
|
||||
|
||||
await submitAsterElicitationResponse(
|
||||
sessionId,
|
||||
response.requestId,
|
||||
userData,
|
||||
);
|
||||
} else {
|
||||
await confirmAsterAction(
|
||||
response.requestId,
|
||||
response.confirmed,
|
||||
response.response,
|
||||
);
|
||||
}
|
||||
|
||||
// 移除已处理的请求
|
||||
setPendingActions((prev) =>
|
||||
prev.filter((a) => a.requestId !== response.requestId),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AsterChat] 确认失败:", error);
|
||||
toast.error("确认操作失败");
|
||||
}
|
||||
},
|
||||
[pendingActions, sessionId],
|
||||
);
|
||||
|
||||
// 清空消息
|
||||
const clearMessages = useCallback(() => {
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
import { ArtifactRenderer, ArtifactToolbar } from "@/components/artifact";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { createInitialMusicState } from "@/components/content-creator/canvas/music/types";
|
||||
import {
|
||||
createInitialNovelState,
|
||||
countWords as countNovelWords,
|
||||
} from "@/components/content-creator/canvas/novel/types";
|
||||
import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser";
|
||||
import {
|
||||
generateContentCreationPrompt,
|
||||
@@ -64,7 +68,11 @@ import { SettingsTabs } from "@/types/settings";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
|
||||
import type { MessageImage } from "./types";
|
||||
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
|
||||
import type {
|
||||
ThemeType,
|
||||
LayoutMode,
|
||||
StepStatus,
|
||||
} from "@/components/content-creator/types";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import { getFileToStepMap } from "./utils/workflowMapping";
|
||||
|
||||
@@ -92,7 +100,6 @@ const PageContainer = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const MainArea = styled.div`
|
||||
@@ -112,6 +119,15 @@ const ChatContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ChatContainerInner = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ChatContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -135,6 +151,15 @@ function projectTypeToTheme(projectType: ProjectType): ThemeType {
|
||||
return projectType as ThemeType;
|
||||
}
|
||||
|
||||
export interface WorkflowProgressSnapshot {
|
||||
steps: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: StepStatus;
|
||||
}>;
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断画布状态是否为空
|
||||
* 用于决定是否自动触发 AI 引导
|
||||
@@ -183,6 +208,10 @@ export function AgentChatPage({
|
||||
initialCreationMode,
|
||||
lockTheme = false,
|
||||
hideHistoryToggle = false,
|
||||
showChatPanel = true,
|
||||
onBackToProjectManagement,
|
||||
hideInlineStepProgress = false,
|
||||
onWorkflowProgressChange,
|
||||
newChatAt,
|
||||
onRecommendationClick: _onRecommendationClick,
|
||||
onHasMessagesChange,
|
||||
@@ -194,6 +223,12 @@ export function AgentChatPage({
|
||||
initialCreationMode?: CreationMode;
|
||||
lockTheme?: boolean;
|
||||
hideHistoryToggle?: boolean;
|
||||
showChatPanel?: boolean;
|
||||
onBackToProjectManagement?: () => void;
|
||||
hideInlineStepProgress?: boolean;
|
||||
onWorkflowProgressChange?: (
|
||||
snapshot: WorkflowProgressSnapshot | null,
|
||||
) => void;
|
||||
newChatAt?: number;
|
||||
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
|
||||
onHasMessagesChange?: (hasMessages: boolean) => void;
|
||||
@@ -230,6 +265,8 @@ export function AgentChatPage({
|
||||
|
||||
// 画布状态(支持多种画布类型)
|
||||
const [canvasState, setCanvasState] = useState<CanvasStateUnion | null>(null);
|
||||
const [novelChapterListCollapsed, setNovelChapterListCollapsed] =
|
||||
useState(false);
|
||||
|
||||
// General 主题专用画布状态
|
||||
const [generalCanvasState, setGeneralCanvasState] =
|
||||
@@ -624,6 +661,63 @@ export function AgentChatPage({
|
||||
[],
|
||||
);
|
||||
|
||||
const looksLikeSerializedNovelState = useCallback((content: string) => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const jsonCandidate =
|
||||
trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed;
|
||||
|
||||
if (!(jsonCandidate.startsWith("[") || jsonCandidate.startsWith("{"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
jsonCandidate.includes('"title"') &&
|
||||
(jsonCandidate.includes('"number"') ||
|
||||
jsonCandidate.includes('"chapters"'))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const upsertNovelCanvasState = useCallback(
|
||||
(prev: CanvasStateUnion | null, content: string) => {
|
||||
if (!prev || prev.type !== "novel") {
|
||||
return createInitialNovelState(content);
|
||||
}
|
||||
|
||||
if (looksLikeSerializedNovelState(content)) {
|
||||
return createInitialNovelState(content);
|
||||
}
|
||||
|
||||
const targetChapterId =
|
||||
prev.currentChapterId || prev.chapters[0]?.id || crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
if (prev.chapters.length === 0) {
|
||||
const initialized = createInitialNovelState(content);
|
||||
return {
|
||||
...initialized,
|
||||
currentChapterId: initialized.chapters[0]?.id || targetChapterId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
chapters: prev.chapters.map((chapter) =>
|
||||
chapter.id === targetChapterId
|
||||
? {
|
||||
...chapter,
|
||||
content,
|
||||
wordCount: countNovelWords(content),
|
||||
updatedAt: now,
|
||||
}
|
||||
: chapter,
|
||||
),
|
||||
};
|
||||
},
|
||||
[looksLikeSerializedNovelState],
|
||||
);
|
||||
|
||||
// 监听 AI 消息变化,自动提取文档内容
|
||||
useEffect(() => {
|
||||
if (!isContentCreationMode) return;
|
||||
@@ -653,6 +747,10 @@ export function AgentChatPage({
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, docContent);
|
||||
}
|
||||
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(docContent);
|
||||
}
|
||||
@@ -674,7 +772,13 @@ export function AgentChatPage({
|
||||
// 自动打开画布
|
||||
setLayoutMode("chat-canvas");
|
||||
}
|
||||
}, [messages, isContentCreationMode, extractDocumentContent, mappedTheme]);
|
||||
}, [
|
||||
messages,
|
||||
isContentCreationMode,
|
||||
extractDocumentContent,
|
||||
mappedTheme,
|
||||
upsertNovelCanvasState,
|
||||
]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (
|
||||
@@ -782,6 +886,51 @@ export function AgentChatPage({
|
||||
// 当开始对话时自动折叠侧边栏
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasState || canvasState.type !== "novel") {
|
||||
setNovelChapterListCollapsed(false);
|
||||
}
|
||||
}, [canvasState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showChatPanel) {
|
||||
setLayoutMode((previous) =>
|
||||
previous === "canvas" ? "chat-canvas" : previous,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSidebar(false);
|
||||
|
||||
if (layoutMode === "canvas") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutMode === "chat-canvas") {
|
||||
setLayoutMode("canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackContent = "# 新文档\n\n在这里开始编写内容...";
|
||||
|
||||
if (activeTheme === "general") {
|
||||
setGeneralCanvasState((previous) => ({
|
||||
...previous,
|
||||
isOpen: true,
|
||||
contentType:
|
||||
previous.contentType === "empty" ? "markdown" : previous.contentType,
|
||||
content: previous.content || fallbackContent,
|
||||
}));
|
||||
} else if (!canvasState) {
|
||||
const initialState =
|
||||
createInitialCanvasState(mappedTheme, fallbackContent) ||
|
||||
createInitialDocumentState(fallbackContent);
|
||||
setCanvasState(initialState);
|
||||
}
|
||||
|
||||
setLayoutMode("canvas");
|
||||
}, [showChatPanel, layoutMode, activeTheme, canvasState, mappedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
onHasMessagesChange?.(hasMessages);
|
||||
}, [hasMessages, onHasMessagesChange]);
|
||||
@@ -833,6 +982,11 @@ export function AgentChatPage({
|
||||
}
|
||||
return { ...prev, sections };
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, latestContent);
|
||||
}
|
||||
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(latestContent);
|
||||
}
|
||||
@@ -841,12 +995,48 @@ export function AgentChatPage({
|
||||
setLayoutMode("chat-canvas");
|
||||
}
|
||||
}
|
||||
}, [taskFiles, mappedTheme]);
|
||||
}, [taskFiles, mappedTheme, upsertNovelCanvasState]);
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (!showChatPanel) {
|
||||
return;
|
||||
}
|
||||
setShowSidebar(!showSidebar);
|
||||
};
|
||||
|
||||
const handleToggleNovelChapterList = useCallback(() => {
|
||||
setNovelChapterListCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleAddNovelChapter = useCallback(() => {
|
||||
setCanvasState((prev) => {
|
||||
if (!prev || prev.type !== "novel") {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const chapterNumber = prev.chapters.length + 1;
|
||||
const title = `第${chapterNumber}章`;
|
||||
const newChapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: chapterNumber,
|
||||
title,
|
||||
content: `# ${title}\n\n`,
|
||||
wordCount: 0,
|
||||
status: "draft" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
chapters: [...prev.chapters, newChapter],
|
||||
currentChapterId: newChapter.id,
|
||||
};
|
||||
});
|
||||
setNovelChapterListCollapsed(false);
|
||||
}, []);
|
||||
|
||||
// 切换画布显示
|
||||
const handleToggleCanvas = useCallback(() => {
|
||||
// General 主题使用专门的画布
|
||||
@@ -883,12 +1073,16 @@ export function AgentChatPage({
|
||||
// 关闭画布
|
||||
const handleCloseCanvas = useCallback(() => {
|
||||
setLayoutMode("chat");
|
||||
setNovelChapterListCollapsed(false);
|
||||
// General 主题关闭画布状态
|
||||
if (activeTheme === "general") {
|
||||
setGeneralCanvasState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, [activeTheme]);
|
||||
|
||||
const showNovelNavbarControls =
|
||||
layoutMode !== "chat" && canvasState?.type === "novel";
|
||||
|
||||
// 处理文件写入 - 同名文件更新内容,不同名文件独立保存
|
||||
const handleWriteFile = useCallback(
|
||||
(content: string, fileName: string) => {
|
||||
@@ -1072,6 +1266,10 @@ export function AgentChatPage({
|
||||
};
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, content);
|
||||
}
|
||||
|
||||
// 文档类型画布
|
||||
if (!prev || prev.type !== "document") {
|
||||
console.log("[AgentChatPage] 创建新文档状态");
|
||||
@@ -1095,6 +1293,7 @@ export function AgentChatPage({
|
||||
completeStep,
|
||||
mappedTheme,
|
||||
saveSessionFile,
|
||||
upsertNovelCanvasState,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1187,6 +1386,10 @@ export function AgentChatPage({
|
||||
return { ...prev, sections };
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, content);
|
||||
}
|
||||
|
||||
// 文档类型画布
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(content);
|
||||
@@ -1200,7 +1403,7 @@ export function AgentChatPage({
|
||||
// 打开画布
|
||||
setLayoutMode("chat-canvas");
|
||||
},
|
||||
[activeTheme, mappedTheme],
|
||||
[activeTheme, mappedTheme, upsertNovelCanvasState],
|
||||
);
|
||||
|
||||
// 处理代码块点击 - 在画布中显示代码(General 主题专用)
|
||||
@@ -1238,7 +1441,7 @@ export function AgentChatPage({
|
||||
// 判断是否应该折叠代码块(当画布打开且有 artifact 时)
|
||||
const shouldCollapseCodeBlocks = useMemo(() => {
|
||||
if (activeTheme !== "general") return false;
|
||||
if (layoutMode !== "chat-canvas") return false;
|
||||
if (layoutMode === "chat") return false;
|
||||
// 当画布打开时折叠代码块
|
||||
return artifacts.length > 0 || generalCanvasState.isOpen;
|
||||
}, [activeTheme, layoutMode, artifacts.length, generalCanvasState.isOpen]);
|
||||
@@ -1264,6 +1467,10 @@ export function AgentChatPage({
|
||||
return { ...prev, sections };
|
||||
}
|
||||
|
||||
if (mappedTheme === "novel") {
|
||||
return upsertNovelCanvasState(prev, file.content!);
|
||||
}
|
||||
|
||||
// 文档类型画布
|
||||
if (!prev || prev.type !== "document") {
|
||||
return createInitialDocumentState(file.content!);
|
||||
@@ -1277,7 +1484,7 @@ export function AgentChatPage({
|
||||
setLayoutMode("chat-canvas");
|
||||
}
|
||||
},
|
||||
[mappedTheme],
|
||||
[mappedTheme, upsertNovelCanvasState],
|
||||
);
|
||||
|
||||
// A2UI 表单提交处理
|
||||
@@ -1349,6 +1556,57 @@ export function AgentChatPage({
|
||||
// 判断是否应该显示聊天布局(有消息)
|
||||
const showChatLayout = hasMessages;
|
||||
|
||||
const workflowProgressSignature = useMemo(() => {
|
||||
const shouldShow = isContentCreationMode && hasMessages && steps.length > 0;
|
||||
if (!shouldShow) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
const stepSignature = steps
|
||||
.map((step) => `${step.id}:${step.status}:${step.title}`)
|
||||
.join("|");
|
||||
return `${currentStepIndex}:${stepSignature}`;
|
||||
}, [isContentCreationMode, hasMessages, steps, currentStepIndex]);
|
||||
|
||||
const lastWorkflowProgressSignatureRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (!onWorkflowProgressChange) return;
|
||||
if (
|
||||
lastWorkflowProgressSignatureRef.current === workflowProgressSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastWorkflowProgressSignatureRef.current = workflowProgressSignature;
|
||||
|
||||
const shouldShow = isContentCreationMode && hasMessages && steps.length > 0;
|
||||
if (!shouldShow) {
|
||||
onWorkflowProgressChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onWorkflowProgressChange({
|
||||
currentIndex: currentStepIndex,
|
||||
steps: steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
status: step.status,
|
||||
})),
|
||||
});
|
||||
}, [
|
||||
onWorkflowProgressChange,
|
||||
workflowProgressSignature,
|
||||
isContentCreationMode,
|
||||
hasMessages,
|
||||
steps,
|
||||
currentStepIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onWorkflowProgressChange?.(null);
|
||||
};
|
||||
}, [onWorkflowProgressChange]);
|
||||
|
||||
const handleManageProviders = useCallback(() => {
|
||||
_onNavigate?.("settings", {
|
||||
tab: SettingsTabs.Providers,
|
||||
@@ -1358,90 +1616,95 @@ export function AgentChatPage({
|
||||
// 聊天区域内容
|
||||
const chatContent = (
|
||||
<ChatContainer>
|
||||
{/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */}
|
||||
{isContentCreationMode && hasMessages && steps.length > 0 && (
|
||||
<StepProgress
|
||||
steps={steps}
|
||||
currentIndex={currentStepIndex}
|
||||
onStepClick={goToStep}
|
||||
/>
|
||||
)}
|
||||
<ChatContainerInner>
|
||||
{/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */}
|
||||
{!hideInlineStepProgress &&
|
||||
isContentCreationMode &&
|
||||
hasMessages &&
|
||||
steps.length > 0 && (
|
||||
<StepProgress
|
||||
steps={steps}
|
||||
currentIndex={currentStepIndex}
|
||||
onStepClick={goToStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showChatLayout ? (
|
||||
<ChatContent>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
onDeleteMessage={deleteMessage}
|
||||
onEditMessage={editMessage}
|
||||
onA2UISubmit={handleA2UISubmit}
|
||||
onWriteFile={handleWriteFile}
|
||||
onFileClick={handleFileClick}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
collapseCodeBlocks={shouldCollapseCodeBlocks}
|
||||
onCodeBlockClick={handleCodeBlockClick}
|
||||
/>
|
||||
</ChatContent>
|
||||
) : (
|
||||
<EmptyState
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={(text) => {
|
||||
handleSend([], false, false, text);
|
||||
}}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
onManageProviders={handleManageProviders}
|
||||
creationMode={creationMode}
|
||||
onCreationModeChange={setCreationMode}
|
||||
activeTheme={activeTheme}
|
||||
onThemeChange={(theme) => {
|
||||
if (!lockTheme) {
|
||||
setActiveTheme(theme);
|
||||
}
|
||||
}}
|
||||
showThemeTabs={false}
|
||||
onRecommendationClick={(shortLabel, fullPrompt) => {
|
||||
// 直接将推荐提示词放入输入框,不创建项目
|
||||
setInput(fullPrompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showChatLayout && (
|
||||
<>
|
||||
<Inputbar
|
||||
{showChatLayout ? (
|
||||
<ChatContent>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
onDeleteMessage={deleteMessage}
|
||||
onEditMessage={editMessage}
|
||||
onA2UISubmit={handleA2UISubmit}
|
||||
onWriteFile={handleWriteFile}
|
||||
onFileClick={handleFileClick}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
collapseCodeBlocks={shouldCollapseCodeBlocks}
|
||||
onCodeBlockClick={handleCodeBlockClick}
|
||||
/>
|
||||
</ChatContent>
|
||||
) : (
|
||||
<EmptyState
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={handleSend}
|
||||
onStop={stopSending}
|
||||
isLoading={isSending}
|
||||
onSend={(text) => {
|
||||
handleSend([], false, false, text);
|
||||
}}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
onManageProviders={handleManageProviders}
|
||||
disabled={!projectId}
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleCanvas={handleToggleCanvas}
|
||||
isCanvasOpen={layoutMode === "chat-canvas"}
|
||||
taskFiles={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
taskFilesExpanded={taskFilesExpanded}
|
||||
onToggleTaskFiles={() => setTaskFilesExpanded(!taskFilesExpanded)}
|
||||
onTaskFileClick={handleTaskFileClick}
|
||||
characters={projectMemory?.characters || []}
|
||||
onSelectCharacter={(character) => {
|
||||
setMentionedCharacters((prev) => {
|
||||
// 避免重复添加
|
||||
if (prev.find((c) => c.id === character.id)) return prev;
|
||||
return [...prev, character];
|
||||
});
|
||||
creationMode={creationMode}
|
||||
onCreationModeChange={setCreationMode}
|
||||
activeTheme={activeTheme}
|
||||
onThemeChange={(theme) => {
|
||||
if (!lockTheme) {
|
||||
setActiveTheme(theme);
|
||||
}
|
||||
}}
|
||||
showThemeTabs={false}
|
||||
onRecommendationClick={(shortLabel, fullPrompt) => {
|
||||
// 直接将推荐提示词放入输入框,不创建项目
|
||||
setInput(fullPrompt);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{showChatLayout && (
|
||||
<>
|
||||
<Inputbar
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={handleSend}
|
||||
onStop={stopSending}
|
||||
isLoading={isSending}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
onManageProviders={handleManageProviders}
|
||||
disabled={!projectId}
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleCanvas={handleToggleCanvas}
|
||||
isCanvasOpen={layoutMode !== "chat"}
|
||||
taskFiles={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
taskFilesExpanded={taskFilesExpanded}
|
||||
onToggleTaskFiles={() => setTaskFilesExpanded(!taskFilesExpanded)}
|
||||
onTaskFileClick={handleTaskFileClick}
|
||||
characters={projectMemory?.characters || []}
|
||||
onSelectCharacter={(character) => {
|
||||
setMentionedCharacters((prev) => {
|
||||
// 避免重复添加
|
||||
if (prev.find((c) => c.id === character.id)) return prev;
|
||||
return [...prev, character];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ChatContainerInner>
|
||||
</ChatContainer>
|
||||
);
|
||||
|
||||
@@ -1503,6 +1766,15 @@ export function AgentChatPage({
|
||||
onStateChange={setCanvasState}
|
||||
onClose={handleCloseCanvas}
|
||||
isStreaming={isSending}
|
||||
novelControls={
|
||||
canvasState.type === "novel"
|
||||
? {
|
||||
useExternalToolbar: true,
|
||||
chapterListCollapsed: novelChapterListCollapsed,
|
||||
onChapterListCollapsedChange: setNovelChapterListCollapsed,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1518,6 +1790,7 @@ export function AgentChatPage({
|
||||
isSending,
|
||||
artifactViewMode,
|
||||
artifactPreviewSize,
|
||||
novelChapterListCollapsed,
|
||||
]);
|
||||
|
||||
// ========== 渲染逻辑 ==========
|
||||
@@ -1526,7 +1799,7 @@ export function AgentChatPage({
|
||||
// General 主题与其他主题的区别仅在于不显示步骤进度条
|
||||
return (
|
||||
<PageContainer>
|
||||
{showSidebar && (
|
||||
{showChatPanel && showSidebar && (
|
||||
<ChatSidebar
|
||||
onNewChat={handleClearMessages}
|
||||
topics={topics}
|
||||
@@ -1541,8 +1814,9 @@ export function AgentChatPage({
|
||||
<ChatNavbar
|
||||
isRunning={isSending}
|
||||
onToggleHistory={handleToggleSidebar}
|
||||
showHistoryToggle={!hideHistoryToggle}
|
||||
showHistoryToggle={!hideHistoryToggle && showChatPanel}
|
||||
onToggleFullscreen={() => {}}
|
||||
onBackToProjectManagement={onBackToProjectManagement}
|
||||
projectId={projectId ?? null}
|
||||
onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)}
|
||||
workspaceType={activeTheme}
|
||||
@@ -1552,6 +1826,16 @@ export function AgentChatPage({
|
||||
tab: SettingsTabs.ChatAppearance,
|
||||
});
|
||||
}}
|
||||
novelCanvasControls={
|
||||
showNovelNavbarControls
|
||||
? {
|
||||
chapterListCollapsed: novelChapterListCollapsed,
|
||||
onToggleChapterList: handleToggleNovelChapterList,
|
||||
onAddChapter: handleAddNovelChapter,
|
||||
onCloseCanvas: handleCloseCanvas,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 同步状态指示器 */}
|
||||
|
||||
@@ -63,6 +63,10 @@ export interface ConfirmResponse {
|
||||
confirmed: boolean;
|
||||
/** 响应内容(用户输入或选择的答案) */
|
||||
response?: string;
|
||||
/** 操作类型(用于前端分流) */
|
||||
actionType?: ActionRequired["actionType"];
|
||||
/** 原始用户数据(用于 elicitation) */
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -32,6 +32,15 @@ interface CanvasFactoryProps {
|
||||
onClose: () => void;
|
||||
/** 是否正在流式输出(仅文档画布使用) */
|
||||
isStreaming?: boolean;
|
||||
/** 小说画布控制配置(可选) */
|
||||
novelControls?: {
|
||||
/** 是否由外部导航栏接管控制按钮 */
|
||||
useExternalToolbar: boolean;
|
||||
/** 章节栏是否折叠 */
|
||||
chapterListCollapsed: boolean;
|
||||
/** 章节栏折叠状态变更 */
|
||||
onChapterListCollapsedChange: (collapsed: boolean) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +50,7 @@ interface CanvasFactoryProps {
|
||||
* 优先使用 state.type 来决定渲染哪个画布,以支持 general 等主题
|
||||
*/
|
||||
export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
({ theme, state, onStateChange, onClose, isStreaming }) => {
|
||||
({ theme, state, onStateChange, onClose, isStreaming, novelControls }) => {
|
||||
// 优先根据 state.type 渲染,这样 general 主题也能显示文档画布
|
||||
// 只有当 state.type 与 theme 对应的 canvasType 不匹配时才检查 theme
|
||||
const canvasType = useMemo(() => {
|
||||
@@ -102,6 +111,11 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: NovelCanvasState) => void}
|
||||
onClose={onClose}
|
||||
useExternalToolbar={novelControls?.useExternalToolbar}
|
||||
chapterListCollapsed={novelControls?.chapterListCollapsed}
|
||||
onChapterListCollapsedChange={
|
||||
novelControls?.onChapterListCollapsedChange
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,21 @@ import { NotionEditor } from "./editor";
|
||||
import { PlatformTabs } from "./PlatformTabs";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ContentArea = styled.div`
|
||||
@@ -171,41 +181,43 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<DocumentToolbar
|
||||
currentVersion={currentVersion}
|
||||
versions={state.versions}
|
||||
isEditing={state.isEditing}
|
||||
onVersionChange={handleVersionChange}
|
||||
onEditToggle={handleEditToggle}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<InnerContainer>
|
||||
<DocumentToolbar
|
||||
currentVersion={currentVersion}
|
||||
versions={state.versions}
|
||||
isEditing={state.isEditing}
|
||||
onVersionChange={handleVersionChange}
|
||||
onEditToggle={handleEditToggle}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ContentArea>
|
||||
{state.isEditing ? (
|
||||
<NotionEditor
|
||||
content={editingContent}
|
||||
onChange={setEditingContent}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<DocumentRenderer
|
||||
content={state.content}
|
||||
platform={state.platform}
|
||||
isStreaming={isStreaming}
|
||||
<ContentArea>
|
||||
{state.isEditing ? (
|
||||
<NotionEditor
|
||||
content={editingContent}
|
||||
onChange={setEditingContent}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<DocumentRenderer
|
||||
content={state.content}
|
||||
platform={state.platform}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</ContentArea>
|
||||
|
||||
{!state.isEditing && (
|
||||
<PlatformTabs
|
||||
currentPlatform={state.platform}
|
||||
onPlatformChange={handlePlatformChange}
|
||||
/>
|
||||
)}
|
||||
</ContentArea>
|
||||
|
||||
{!state.isEditing && (
|
||||
<PlatformTabs
|
||||
currentPlatform={state.platform}
|
||||
onPlatformChange={handlePlatformChange}
|
||||
/>
|
||||
)}
|
||||
</InnerContainer>
|
||||
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
</Container>
|
||||
|
||||
@@ -174,7 +174,7 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = memo(
|
||||
return (
|
||||
<Container>
|
||||
<LeftSection>
|
||||
<Title>📄 文档预览</Title>
|
||||
<Title>文档</Title>
|
||||
<VersionSelector
|
||||
currentVersion={currentVersion}
|
||||
versions={versions}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.notion-editor-wrapper .ProseMirror {
|
||||
min-height: 100%;
|
||||
padding: 24px 32px;
|
||||
padding: 16px 24px;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
|
||||
@@ -27,11 +27,21 @@ const SECTION_DISPLAY_NAMES: Record<string, string> = {
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ContentArea = styled.div`
|
||||
@@ -369,77 +379,85 @@ export const MusicCanvas: React.FC<MusicCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MusicToolbar
|
||||
spec={state.spec}
|
||||
viewMode={state.viewMode}
|
||||
isPlaying={state.isPlaying}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onPlayToggle={handlePlayToggle}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<InnerContainer>
|
||||
<MusicToolbar
|
||||
spec={state.spec}
|
||||
viewMode={state.viewMode}
|
||||
isPlaying={state.isPlaying}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onPlayToggle={handlePlayToggle}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ContentArea>
|
||||
<MainContent>
|
||||
<EditorPane>
|
||||
<SectionTitle>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
{state.viewMode === "lyrics" && "🎤 歌词"}
|
||||
{state.viewMode === "numbered" && "🎼 简谱"}
|
||||
{state.viewMode === "guitar" && "🎸 吉他谱"}
|
||||
{state.viewMode === "piano" && "🎹 钢琴谱"}
|
||||
{isStreaming && (
|
||||
<span style={{ fontSize: 12, color: "hsl(var(--accent))" }}>
|
||||
生成中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{state.viewMode === "lyrics" && (
|
||||
<button
|
||||
onClick={handleCopyLyrics}
|
||||
<ContentArea>
|
||||
<MainContent>
|
||||
<EditorPane>
|
||||
<SectionTitle>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
color: isCopied
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
transition: "color 0.2s",
|
||||
gap: "8px",
|
||||
}}
|
||||
title="复制歌词"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{isCopied ? "已复制" : "复制"}
|
||||
</button>
|
||||
)}
|
||||
</SectionTitle>
|
||||
{renderContent()}
|
||||
</EditorPane>
|
||||
</MainContent>
|
||||
</ContentArea>
|
||||
{state.viewMode === "lyrics" && "🎤 歌词"}
|
||||
{state.viewMode === "numbered" && "🎼 简谱"}
|
||||
{state.viewMode === "guitar" && "🎸 吉他谱"}
|
||||
{state.viewMode === "piano" && "🎹 钢琴谱"}
|
||||
{isStreaming && (
|
||||
<span
|
||||
style={{ fontSize: 12, color: "hsl(var(--accent))" }}
|
||||
>
|
||||
生成中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{state.viewMode === "lyrics" && (
|
||||
<button
|
||||
onClick={handleCopyLyrics}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
color: isCopied
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
transition: "color 0.2s",
|
||||
}}
|
||||
title="复制歌词"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{isCopied ? "已复制" : "复制"}
|
||||
</button>
|
||||
)}
|
||||
</SectionTitle>
|
||||
{renderContent()}
|
||||
</EditorPane>
|
||||
</MainContent>
|
||||
</ContentArea>
|
||||
|
||||
<StatusBar>
|
||||
<StatusItem>
|
||||
🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM
|
||||
</StatusItem>
|
||||
<StatusItem>
|
||||
{stats.totalSections} 段 | {stats.totalLines} 行 |{" "}
|
||||
{stats.totalChars} 字
|
||||
</StatusItem>
|
||||
</StatusBar>
|
||||
<StatusBar>
|
||||
<StatusItem>
|
||||
🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM
|
||||
</StatusItem>
|
||||
<StatusItem>
|
||||
{stats.totalSections} 段 | {stats.totalLines} 行 |{" "}
|
||||
{stats.totalChars} 字
|
||||
</StatusItem>
|
||||
</StatusBar>
|
||||
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -54,6 +54,13 @@ const RightSection = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const ThemeLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const SongTitle = styled.h2`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -199,6 +206,7 @@ export const MusicToolbar: React.FC<MusicToolbarProps> = memo(
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<LeftSection>
|
||||
<ThemeLabel>音乐</ThemeLabel>
|
||||
<SongTitle>{spec.title}</SongTitle>
|
||||
<SongMeta>
|
||||
{spec.key} | {spec.tempo} BPM
|
||||
|
||||
@@ -4,13 +4,19 @@
|
||||
* 用于小说项目的章节编辑
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from "react";
|
||||
import React, { memo, useCallback, useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { X, Plus, FileText, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} 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 { NotionEditor } from "@/components/content-creator/canvas/document/editor";
|
||||
import type { NovelCanvasState, Chapter } from "./types";
|
||||
import { countWords } from "./types";
|
||||
|
||||
@@ -18,51 +24,45 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--muted) / 0.18);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
padding: 12px 16px;
|
||||
background: hsl(var(--background));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const HeaderInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const HeaderMeta = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: hsl(var(--background));
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const ChapterList = styled.div`
|
||||
width: 236px;
|
||||
min-width: 236px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--muted) / 0.28);
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const ChapterListHeader = styled.div`
|
||||
@@ -78,6 +78,22 @@ const ChapterListBody = styled.div`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ChapterListFooter = styled.div`
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: hsl(var(--background));
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const StatItem = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ChapterItem = styled.div<{ $active?: boolean }>`
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
@@ -127,43 +143,21 @@ const EditorArea = styled.div`
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const ChapterHeader = styled.div`
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: hsl(var(--muted) / 0.16);
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const Editor = styled(Textarea)`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.95;
|
||||
resize: none;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--background));
|
||||
padding: 18px 20px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
|
||||
&:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyEditorState = styled.div`
|
||||
flex: 1;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
@@ -175,43 +169,102 @@ const EmptyEditorState = styled.div`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StatusBar = styled.div`
|
||||
padding: 9px 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));
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
interface NovelCanvasProps {
|
||||
state: NovelCanvasState;
|
||||
onStateChange: (state: NovelCanvasState) => void;
|
||||
onClose: () => void;
|
||||
useExternalToolbar?: boolean;
|
||||
chapterListCollapsed?: boolean;
|
||||
onChapterListCollapsedChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保章节内容第一行是标题格式 (# 标题)
|
||||
*/
|
||||
function ensureChapterTitleInContent(title: string, content: string): string {
|
||||
const lines = content.split("\n");
|
||||
const firstLine = lines[0] || "";
|
||||
|
||||
// 如果第一行已经是 H1 标题,更新它
|
||||
if (firstLine.startsWith("# ")) {
|
||||
lines[0] = `# ${title}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// 否则在开头插入标题
|
||||
return `# ${title}\n\n${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从章节内容中提取标题 (第一行如果是 # 格式)
|
||||
*/
|
||||
function extractTitleFromContent(content: string): string | null {
|
||||
const lines = content.split("\n");
|
||||
const firstLine = lines[0] || "";
|
||||
|
||||
if (firstLine.startsWith("# ")) {
|
||||
return firstLine.substring(2).trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理章节内容,移除第一行标题
|
||||
*/
|
||||
function _sanitizeChapterContent(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
if (lines[0]?.startsWith("# ")) {
|
||||
return lines.slice(1).join("\n").trim();
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
({ state, onStateChange, onClose }) => {
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
onClose,
|
||||
useExternalToolbar = false,
|
||||
chapterListCollapsed,
|
||||
onChapterListCollapsedChange,
|
||||
}) => {
|
||||
const [internalChapterListCollapsed, setInternalChapterListCollapsed] =
|
||||
useState(false);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const isChapterListCollapsed =
|
||||
chapterListCollapsed ?? internalChapterListCollapsed;
|
||||
const currentChapter = state.chapters.find(
|
||||
(c) => c.id === state.currentChapterId,
|
||||
);
|
||||
|
||||
const setChapterListCollapsed = useCallback(
|
||||
(collapsed: boolean) => {
|
||||
onChapterListCollapsedChange?.(collapsed);
|
||||
if (chapterListCollapsed === undefined) {
|
||||
setInternalChapterListCollapsed(collapsed);
|
||||
}
|
||||
},
|
||||
[chapterListCollapsed, onChapterListCollapsedChange],
|
||||
);
|
||||
|
||||
const handleChapterSelect = useCallback(
|
||||
(chapterId: string) => {
|
||||
onStateChange({ ...state, currentChapterId: chapterId });
|
||||
setEditorKey((prev) => prev + 1);
|
||||
},
|
||||
[state, onStateChange],
|
||||
);
|
||||
|
||||
const handleAddChapter = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const chapterNumber = state.chapters.length + 1;
|
||||
const title = `第${chapterNumber}章`;
|
||||
const newChapter: Chapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: state.chapters.length + 1,
|
||||
title: `第${state.chapters.length + 1}章`,
|
||||
content: "",
|
||||
number: chapterNumber,
|
||||
title,
|
||||
content: `# ${title}\n\n`,
|
||||
wordCount: 0,
|
||||
status: "draft",
|
||||
createdAt: now,
|
||||
@@ -222,20 +275,24 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
chapters: [...state.chapters, newChapter],
|
||||
currentChapterId: newChapter.id,
|
||||
});
|
||||
setEditorKey((prev) => prev + 1);
|
||||
}, [state, onStateChange]);
|
||||
|
||||
const handleUpdateChapter = useCallback(
|
||||
(updates: Partial<Chapter>) => {
|
||||
(content: string) => {
|
||||
if (!currentChapter) return;
|
||||
|
||||
// 从内容中提取标题
|
||||
const extractedTitle = extractTitleFromContent(content);
|
||||
const now = Date.now();
|
||||
|
||||
const updatedChapters = state.chapters.map((c) =>
|
||||
c.id === currentChapter.id
|
||||
? {
|
||||
...c,
|
||||
...updates,
|
||||
wordCount: updates.content
|
||||
? countWords(updates.content)
|
||||
: c.wordCount,
|
||||
title: extractedTitle || c.title,
|
||||
content,
|
||||
wordCount: countWords(content),
|
||||
updatedAt: now,
|
||||
}
|
||||
: c,
|
||||
@@ -245,6 +302,43 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
[state, currentChapter, onStateChange],
|
||||
);
|
||||
|
||||
const handleToggleStatus = useCallback(() => {
|
||||
if (!currentChapter) return;
|
||||
const now = Date.now();
|
||||
const updatedChapters: Chapter[] = state.chapters.map(
|
||||
(c): Chapter =>
|
||||
c.id === currentChapter.id
|
||||
? {
|
||||
...c,
|
||||
status: c.status === "completed" ? "draft" : "completed",
|
||||
updatedAt: now,
|
||||
}
|
||||
: c,
|
||||
);
|
||||
onStateChange({ ...state, chapters: updatedChapters });
|
||||
}, [state, currentChapter, onStateChange]);
|
||||
|
||||
// 确保当前章节内容包含标题
|
||||
useEffect(() => {
|
||||
if (currentChapter) {
|
||||
const extractedTitle = extractTitleFromContent(currentChapter.content);
|
||||
if (!extractedTitle || extractedTitle !== currentChapter.title) {
|
||||
const updatedContent = ensureChapterTitleInContent(
|
||||
currentChapter.title,
|
||||
currentChapter.content,
|
||||
);
|
||||
if (updatedContent !== currentChapter.content) {
|
||||
const updatedChapters = state.chapters.map((c) =>
|
||||
c.id === currentChapter.id
|
||||
? { ...c, content: updatedContent }
|
||||
: c,
|
||||
);
|
||||
onStateChange({ ...state, chapters: updatedChapters });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentChapter, state, onStateChange]);
|
||||
|
||||
const totalWords = state.chapters.reduce((sum, c) => sum + c.wordCount, 0);
|
||||
const completedCount = state.chapters.filter(
|
||||
(c) => c.status === "completed",
|
||||
@@ -252,111 +346,128 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<HeaderInfo>
|
||||
<Title>小说编辑器</Title>
|
||||
<HeaderMeta>
|
||||
{state.chapters.length} 章 · {totalWords} 字
|
||||
</HeaderMeta>
|
||||
</HeaderInfo>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Header>
|
||||
<InnerContainer>
|
||||
{!useExternalToolbar && (
|
||||
<Header>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleAddChapter}
|
||||
title="新建章节"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Header>
|
||||
)}
|
||||
<Content>
|
||||
{!isChapterListCollapsed && (
|
||||
<ChapterList>
|
||||
<ChapterListHeader>
|
||||
<span className="text-sm font-medium">章节</span>
|
||||
{!useExternalToolbar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setChapterListCollapsed(true)}
|
||||
title="收起章节栏"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</ChapterListHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
<ChapterListBody>
|
||||
{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" />
|
||||
)}
|
||||
<ChapterTitleText>{chapter.title}</ChapterTitleText>
|
||||
</ChapterTitle>
|
||||
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
|
||||
</ChapterItem>
|
||||
))}
|
||||
</ChapterListBody>
|
||||
</ScrollArea>
|
||||
<ChapterListFooter>
|
||||
<StatItem>
|
||||
<span>总章节</span>
|
||||
<span>{state.chapters.length}</span>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<span>已完成</span>
|
||||
<span>{completedCount}</span>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<span>总字数</span>
|
||||
<span>{totalWords.toLocaleString()}</span>
|
||||
</StatItem>
|
||||
</ChapterListFooter>
|
||||
</ChapterList>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<ChapterListBody>
|
||||
{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" />
|
||||
)}
|
||||
<ChapterTitleText>{chapter.title}</ChapterTitleText>
|
||||
</ChapterTitle>
|
||||
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
|
||||
</ChapterItem>
|
||||
))}
|
||||
</ChapterListBody>
|
||||
</ScrollArea>
|
||||
</ChapterList>
|
||||
|
||||
<EditorArea>
|
||||
{currentChapter && (
|
||||
<>
|
||||
<ChapterHeader>
|
||||
<Input
|
||||
value={currentChapter.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateChapter({ title: e.target.value })
|
||||
}
|
||||
placeholder="章节标题"
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
<EditorArea>
|
||||
{!useExternalToolbar && isChapterListCollapsed && (
|
||||
<div className="absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-md border bg-background/90 p-1 shadow-sm">
|
||||
<Button
|
||||
variant={
|
||||
currentChapter.status === "completed"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUpdateChapter({
|
||||
status:
|
||||
currentChapter.status === "completed"
|
||||
? "draft"
|
||||
: "completed",
|
||||
})
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setChapterListCollapsed(false)}
|
||||
title="展开章节栏"
|
||||
>
|
||||
{currentChapter.status === "completed"
|
||||
? "已完成"
|
||||
: "标记完成"}
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</ChapterHeader>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleAddChapter}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{currentChapter && (
|
||||
<EditorContainer>
|
||||
<Editor
|
||||
value={currentChapter.content}
|
||||
onChange={(e) =>
|
||||
handleUpdateChapter({ content: e.target.value })
|
||||
}
|
||||
placeholder="开始写作..."
|
||||
<NotionEditor
|
||||
key={editorKey}
|
||||
content={currentChapter.content}
|
||||
onChange={handleUpdateChapter}
|
||||
onSave={handleToggleStatus}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</EditorContainer>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!currentChapter && (
|
||||
<EditorContainer>
|
||||
<EmptyEditorState>
|
||||
请先选择章节,或在左侧新建章节开始创作
|
||||
</EmptyEditorState>
|
||||
</EditorContainer>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
|
||||
<StatusBar>
|
||||
<span>
|
||||
{completedCount}/{state.chapters.length} 章完成
|
||||
</span>
|
||||
<span>总字数:{totalWords.toLocaleString()}</span>
|
||||
</StatusBar>
|
||||
{!currentChapter && (
|
||||
<EditorContainer>
|
||||
<EmptyEditorState>
|
||||
请先选择章节,或在左侧新建章节开始创作
|
||||
</EmptyEditorState>
|
||||
</EditorContainer>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,15 +33,143 @@ export interface NovelCanvasState {
|
||||
synopsis?: string;
|
||||
}
|
||||
|
||||
/** 计算字数 */
|
||||
export function countWords(text: string): number {
|
||||
// 简单的中文字数统计
|
||||
return text.replace(/\s/g, "").length;
|
||||
}
|
||||
|
||||
function sanitizeNovelContent(raw: string): string {
|
||||
if (!raw) return "";
|
||||
|
||||
let text = raw;
|
||||
|
||||
// 过滤 A2UI 标签块
|
||||
text = text.replace(/<a2ui>[\s\S]*?<\/a2ui>/gi, "");
|
||||
|
||||
// 过滤常见 A2UI fenced code block
|
||||
text = text.replace(/```a2ui[\s\S]*?```/gi, "");
|
||||
|
||||
// 过滤包含 A2UI 特征字段的 JSON code block
|
||||
text = text.replace(/```json\s*([\s\S]*?)```/gi, (block, inner) => {
|
||||
const normalized = String(inner).toLowerCase();
|
||||
const looksLikeA2UI =
|
||||
normalized.includes('"components"') &&
|
||||
(normalized.includes('"type":"form"') ||
|
||||
normalized.includes('"submitaction"') ||
|
||||
normalized.includes('"root"'));
|
||||
|
||||
return looksLikeA2UI ? "" : block;
|
||||
});
|
||||
|
||||
// 过滤误写入正文的章节序列化 JSON(历史数据兼容)
|
||||
const jsonCandidate = text.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || text;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCandidate) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
const looksLikeChapterList = parsed.some(
|
||||
(item) => item && typeof item === "object" && "title" in item,
|
||||
);
|
||||
if (looksLikeChapterList) {
|
||||
const merged = parsed
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
const chapter = item as { content?: unknown };
|
||||
return typeof chapter.content === "string" ? chapter.content : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
|
||||
text = merged;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON,忽略
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function normalizeChapter(raw: Partial<Chapter>, index: number): Chapter {
|
||||
const now = Date.now();
|
||||
const chapterContent = sanitizeNovelContent(raw.content ?? "");
|
||||
|
||||
return {
|
||||
id: raw.id || crypto.randomUUID(),
|
||||
number:
|
||||
typeof raw.number === "number" && raw.number > 0 ? raw.number : index + 1,
|
||||
title:
|
||||
typeof raw.title === "string" && raw.title.trim()
|
||||
? raw.title.trim()
|
||||
: `第${index + 1}章`,
|
||||
content: chapterContent,
|
||||
wordCount: countWords(chapterContent),
|
||||
status: raw.status === "completed" ? "completed" : "draft",
|
||||
createdAt:
|
||||
typeof raw.createdAt === "number" && Number.isFinite(raw.createdAt)
|
||||
? raw.createdAt
|
||||
: now,
|
||||
updatedAt:
|
||||
typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt)
|
||||
? raw.updatedAt
|
||||
: now,
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseSerializedChapters(content: string): Chapter[] | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const jsonCandidate =
|
||||
trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCandidate);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item, index) => normalizeChapter(item || {}, index));
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
Array.isArray((parsed as { chapters?: unknown[] }).chapters)
|
||||
) {
|
||||
const chapters = (parsed as { chapters: unknown[] }).chapters;
|
||||
return chapters.map((item, index) =>
|
||||
normalizeChapter((item as Partial<Chapter>) || {}, index),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建初始小说状态 */
|
||||
export function createInitialNovelState(content?: string): NovelCanvasState {
|
||||
const rawContent = content ?? "";
|
||||
|
||||
const parsedChapters = tryParseSerializedChapters(rawContent);
|
||||
if (parsedChapters && parsedChapters.length > 0) {
|
||||
return {
|
||||
type: "novel",
|
||||
chapters: parsedChapters,
|
||||
currentChapterId: parsedChapters[0].id,
|
||||
outline: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sanitizedContent = sanitizeNovelContent(rawContent);
|
||||
const now = Date.now();
|
||||
const defaultChapter: Chapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: 1,
|
||||
title: "第一章",
|
||||
content: content || "",
|
||||
wordCount: content ? content.length : 0,
|
||||
content: sanitizedContent,
|
||||
wordCount: countWords(sanitizedContent),
|
||||
status: "draft",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -55,12 +183,6 @@ export function createInitialNovelState(content?: string): NovelCanvasState {
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算字数 */
|
||||
export function countWords(text: string): number {
|
||||
// 简单的中文字数统计
|
||||
return text.replace(/\s/g, "").length;
|
||||
}
|
||||
|
||||
/** 将小说状态转换为文本 */
|
||||
export function novelStateToText(state: NovelCanvasState): string {
|
||||
let text = "";
|
||||
|
||||
@@ -31,8 +31,18 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--muted));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const MainArea = styled.div`
|
||||
@@ -335,87 +345,89 @@ export const PosterCanvas: React.FC<PosterCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PosterToolbar
|
||||
zoom={zoom}
|
||||
showGrid={state.showGrid}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
canvasWidth={currentPage?.width || 1080}
|
||||
canvasHeight={currentPage?.height || 1080}
|
||||
onZoomChange={handleZoomChange}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
onToggleLayerPanel={handleToggleLayerPanel}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onExport={handleExport}
|
||||
onSizeChange={handleSizeChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<InnerContainer>
|
||||
<PosterToolbar
|
||||
zoom={zoom}
|
||||
showGrid={state.showGrid}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
canvasWidth={currentPage?.width || 1080}
|
||||
canvasHeight={currentPage?.height || 1080}
|
||||
onZoomChange={handleZoomChange}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
onToggleLayerPanel={handleToggleLayerPanel}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onExport={handleExport}
|
||||
onSizeChange={handleSizeChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<MainArea>
|
||||
<CanvasWrapper ref={wrapperRef}>
|
||||
<CanvasContainer $zoom={zoom}>
|
||||
<canvas ref={canvasRef} />
|
||||
<GridOverlay
|
||||
$show={state.showGrid}
|
||||
$width={currentPage?.width || 0}
|
||||
$height={currentPage?.height || 0}
|
||||
<MainArea>
|
||||
<CanvasWrapper ref={wrapperRef}>
|
||||
<CanvasContainer $zoom={zoom}>
|
||||
<canvas ref={canvasRef} />
|
||||
<GridOverlay
|
||||
$show={state.showGrid}
|
||||
$width={currentPage?.width || 0}
|
||||
$height={currentPage?.height || 0}
|
||||
/>
|
||||
</CanvasContainer>
|
||||
</CanvasWrapper>
|
||||
|
||||
{/* 图层面板 */}
|
||||
{showLayerPanel && (
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={(ids) => {
|
||||
// 选择图层对应的元素
|
||||
if (ids.length === 1) {
|
||||
selectLayerElement(ids[0]);
|
||||
} else if (ids.length > 1) {
|
||||
// 多选时选择第一个
|
||||
selectLayerElement(ids[0]);
|
||||
}
|
||||
}}
|
||||
onReorder={reorderLayer}
|
||||
onToggleVisibility={toggleLayerVisibility}
|
||||
onToggleLock={toggleLayerLock}
|
||||
onRename={renameLayer}
|
||||
onClose={handleToggleLayerPanel}
|
||||
/>
|
||||
</CanvasContainer>
|
||||
</CanvasWrapper>
|
||||
)}
|
||||
</MainArea>
|
||||
|
||||
{/* 图层面板 */}
|
||||
{showLayerPanel && (
|
||||
<LayerPanel
|
||||
layers={layers}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={(ids) => {
|
||||
// 选择图层对应的元素
|
||||
if (ids.length === 1) {
|
||||
selectLayerElement(ids[0]);
|
||||
} else if (ids.length > 1) {
|
||||
// 多选时选择第一个
|
||||
selectLayerElement(ids[0]);
|
||||
}
|
||||
}}
|
||||
onReorder={reorderLayer}
|
||||
onToggleVisibility={toggleLayerVisibility}
|
||||
onToggleLock={toggleLayerLock}
|
||||
onRename={renameLayer}
|
||||
onClose={handleToggleLayerPanel}
|
||||
/>
|
||||
)}
|
||||
</MainArea>
|
||||
<PageList
|
||||
pages={state.pages}
|
||||
currentIndex={state.currentPageIndex}
|
||||
onPageSelect={selectPage}
|
||||
onAddPage={addPage}
|
||||
onDeletePage={deletePage}
|
||||
onDuplicatePage={duplicatePage}
|
||||
onReorderPages={reorderPages}
|
||||
/>
|
||||
|
||||
<PageList
|
||||
pages={state.pages}
|
||||
currentIndex={state.currentPageIndex}
|
||||
onPageSelect={selectPage}
|
||||
onAddPage={addPage}
|
||||
onDeletePage={deletePage}
|
||||
onDuplicatePage={duplicatePage}
|
||||
onReorderPages={reorderPages}
|
||||
/>
|
||||
<ElementToolbar
|
||||
onAddText={handleAddText}
|
||||
onAddImage={handleAddImage}
|
||||
onAddShape={handleAddShape}
|
||||
onSetBackground={handleSetBackground}
|
||||
hasSelection={selectedIds.length > 0}
|
||||
gridSnapEnabled={gridSnapEnabled}
|
||||
onAlign={handleAlign}
|
||||
onToggleGridSnap={toggleGridSnap}
|
||||
/>
|
||||
|
||||
<ElementToolbar
|
||||
onAddText={handleAddText}
|
||||
onAddImage={handleAddImage}
|
||||
onAddShape={handleAddShape}
|
||||
onSetBackground={handleSetBackground}
|
||||
hasSelection={selectedIds.length > 0}
|
||||
gridSnapEnabled={gridSnapEnabled}
|
||||
onAlign={handleAlign}
|
||||
onToggleGridSnap={toggleGridSnap}
|
||||
/>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -37,6 +37,13 @@ import type { PosterToolbarProps } from "./types";
|
||||
import { ZOOM_PRESETS, ZOOM_MIN, ZOOM_MAX } from "./types";
|
||||
import { SizeSelector } from "./SizeSelector";
|
||||
|
||||
const ThemeLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const ToolbarContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -184,8 +191,10 @@ export const PosterToolbar: React.FC<PosterToolbarProps> = memo(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ToolbarContainer>
|
||||
{/* 左侧:缩放控制 */}
|
||||
{/* 左侧:主题标签和缩放控制 */}
|
||||
<ToolbarGroup>
|
||||
<ThemeLabel>海报</ThemeLabel>
|
||||
<Divider />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -14,11 +14,21 @@ 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%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
@@ -200,116 +210,118 @@ export const ScriptCanvas: React.FC<ScriptCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>剧本编辑器</Title>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Header>
|
||||
<InnerContainer>
|
||||
<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>
|
||||
<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>
|
||||
<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}
|
||||
<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, {
|
||||
characterName: e.target.value,
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="角色名"
|
||||
className="w-32"
|
||||
placeholder="对白内容..."
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
<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>
|
||||
</DialogueItem>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleAddDialogue}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加对白
|
||||
</Button>
|
||||
</DialogueList>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</EditorArea>
|
||||
</Content>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,15 +16,31 @@ const Container = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ChatPanel = styled.div<{ $width: string; $duration: number }>`
|
||||
const ChatPanel = styled.div<{
|
||||
$width: string;
|
||||
$duration: number;
|
||||
$minWidth: string;
|
||||
}>`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: width ${({ $duration }) => $duration}ms ease-out;
|
||||
width: ${({ $width }) => $width};
|
||||
min-width: 460px;
|
||||
min-width: ${({ $minWidth }) => $minWidth};
|
||||
will-change: width;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 16px 16px 0;
|
||||
`;
|
||||
|
||||
const ChatPanelInner = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const CanvasPanel = styled.div<{
|
||||
@@ -90,8 +106,9 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
|
||||
$duration={parseInt(
|
||||
chatStyles.transition?.match(/\d+/)?.[0] || "300",
|
||||
)}
|
||||
$minWidth={mode === "canvas" ? "0px" : "460px"}
|
||||
>
|
||||
{chatContent}
|
||||
<ChatPanelInner>{chatContent}</ChatPanelInner>
|
||||
</ChatPanel>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -49,9 +49,7 @@ export function useLayoutTransition(
|
||||
);
|
||||
const [transitionState, setTransitionState] =
|
||||
useState<TransitionState>("idle");
|
||||
const [isCanvasVisible, setIsCanvasVisible] = useState(
|
||||
mode === "chat-canvas",
|
||||
);
|
||||
const [isCanvasVisible, setIsCanvasVisible] = useState(mode !== "chat");
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevModeRef = useRef<LayoutMode>(mode);
|
||||
|
||||
@@ -69,7 +67,7 @@ export function useLayoutTransition(
|
||||
|
||||
clearTimeouts();
|
||||
|
||||
if (mode === "chat-canvas") {
|
||||
if (mode !== "chat") {
|
||||
// 进入画布模式
|
||||
setIsCanvasVisible(true);
|
||||
setTransitionState("entering");
|
||||
@@ -122,7 +120,8 @@ export function useLayoutTransition(
|
||||
// chat 区域 - 画布打开时提升右侧聊天区宽度
|
||||
return {
|
||||
transition: `width ${duration}ms ease-out`,
|
||||
width: mode === "chat-canvas" ? "46%" : "100%",
|
||||
width:
|
||||
mode === "chat-canvas" ? "35%" : mode === "canvas" ? "0%" : "100%",
|
||||
};
|
||||
},
|
||||
[transitionState, mergedConfig, mode],
|
||||
|
||||
@@ -41,7 +41,7 @@ export type CreationMode = "guided" | "fast" | "hybrid" | "framework";
|
||||
/**
|
||||
* 布局模式
|
||||
*/
|
||||
export type LayoutMode = "chat" | "chat-canvas";
|
||||
export type LayoutMode = "chat" | "chat-canvas" | "canvas";
|
||||
|
||||
/**
|
||||
* 步骤类型
|
||||
|
||||
@@ -230,6 +230,407 @@ function getFormInstructions(): string {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小说引导模式(教练模式)的系统提示词
|
||||
* 小说模式使用独立的章节创作引导,避免沿用社媒文章语义
|
||||
*/
|
||||
function generateNovelGuidedModePrompt(
|
||||
themeName: string,
|
||||
themeGuidance: string,
|
||||
): string {
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的小说需求收集表单。**
|
||||
|
||||
不要:
|
||||
- ❌ 直接生成章节正文
|
||||
- ❌ 跳过世界观与角色设定
|
||||
- ❌ 使用“文章写作”话术(如段落、论点、读者收获)
|
||||
|
||||
必须:
|
||||
- ✅ 先收集小说创作要素
|
||||
- ✅ 每一步都等待用户确认
|
||||
- ✅ 全程使用“故事、角色、冲突、章节、场景”语义
|
||||
|
||||
---
|
||||
|
||||
你是一位专业的小说创作教练,当前帮助用户进行「${themeName}」创作。
|
||||
|
||||
## 你的角色:小说教练(章节共创)
|
||||
|
||||
你的职责是帮助用户搭建故事骨架并引导其完成章节,而不是代替用户一次性写完全部剧情。
|
||||
|
||||
### 可以做
|
||||
- ✅ 拆解章节目标、场景节拍和冲突推进
|
||||
- ✅ 引导用户补充角色动机、关系张力、细节动作
|
||||
- ✅ 对用户写出的片段给出节奏和叙事建议
|
||||
|
||||
### 绝对不能做
|
||||
- ❌ 把小说任务当成社媒文章任务
|
||||
- ❌ 输出“文章大纲/论点结构/平台适配”类建议
|
||||
- ❌ 在未确认设定前直接展开完整章节正文
|
||||
|
||||
${getFileWritingInstructions("novel")}
|
||||
|
||||
${getFormInstructions()}
|
||||
|
||||
## 🔄 工作流程(严格按顺序执行)
|
||||
|
||||
### 步骤 1️⃣:收集小说基础设定(必须首先执行)
|
||||
|
||||
**你的第一条回复必须是这个表单,无论用户说什么:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "📚 小说创作引导 - 设定收集",
|
||||
"description": "先确定故事核心,再进入章节创作。",
|
||||
"fields": [
|
||||
{
|
||||
"id": "storyTheme",
|
||||
"type": "text",
|
||||
"label": "故事主题",
|
||||
"placeholder": "例如:记忆交易、末日求生、校园悬疑",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "genre",
|
||||
"type": "choice",
|
||||
"label": "题材类型",
|
||||
"options": [
|
||||
{"value": "fantasy", "label": "奇幻"},
|
||||
{"value": "scifi", "label": "科幻"},
|
||||
{"value": "suspense", "label": "悬疑"},
|
||||
{"value": "romance", "label": "言情"},
|
||||
{"value": "historical", "label": "历史"},
|
||||
{"value": "other", "label": "其他"}
|
||||
],
|
||||
"default": "fantasy"
|
||||
},
|
||||
{
|
||||
"id": "worldSetting",
|
||||
"type": "text",
|
||||
"label": "世界观/时代背景",
|
||||
"placeholder": "描述时代、地点、规则或特殊设定",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "protagonist",
|
||||
"type": "text",
|
||||
"label": "主角设定",
|
||||
"placeholder": "姓名、身份、核心动机、弱点",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "chapterGoal",
|
||||
"type": "text",
|
||||
"label": "本章目标",
|
||||
"placeholder": "这一章要推动什么关键变化?",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "tone",
|
||||
"type": "choice",
|
||||
"label": "叙事文风",
|
||||
"options": [
|
||||
{"value": "cinematic", "label": "电影感"},
|
||||
{"value": "lyrical", "label": "细腻抒情"},
|
||||
{"value": "compact", "label": "紧凑硬朗"},
|
||||
{"value": "light", "label": "轻松日常"}
|
||||
],
|
||||
"default": "compact"
|
||||
},
|
||||
{
|
||||
"id": "chapterLength",
|
||||
"type": "choice",
|
||||
"label": "本章字数",
|
||||
"options": [
|
||||
{"value": "1500", "label": "约1500字"},
|
||||
{"value": "2000", "label": "约2000字"},
|
||||
{"value": "3000", "label": "约3000字"},
|
||||
{"value": "4000", "label": "约4000字"}
|
||||
],
|
||||
"default": "2000"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始构建章节 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2️⃣:收集冲突与场景节拍(表单提交后执行)
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "🎭 情节推进信息",
|
||||
"description": "请补充本章冲突与关键场景。",
|
||||
"fields": [
|
||||
{
|
||||
"id": "coreConflict",
|
||||
"type": "text",
|
||||
"label": "核心冲突",
|
||||
"placeholder": "本章主角面临的冲突是什么?"
|
||||
},
|
||||
{
|
||||
"id": "keyScene",
|
||||
"type": "text",
|
||||
"label": "关键场景",
|
||||
"placeholder": "至少描述一个关键场景(时间/地点/人物/动作)"
|
||||
},
|
||||
{
|
||||
"id": "supportingRoles",
|
||||
"type": "text",
|
||||
"label": "配角与关系变化",
|
||||
"placeholder": "谁会出场?与主角关系如何变化?"
|
||||
},
|
||||
{
|
||||
"id": "turningPoint",
|
||||
"type": "text",
|
||||
"label": "转折点",
|
||||
"placeholder": "本章中段或后段要出现什么反转?"
|
||||
},
|
||||
{
|
||||
"id": "endingHook",
|
||||
"type": "text",
|
||||
"label": "章末钩子",
|
||||
"placeholder": "章节结尾留什么悬念,驱动下一章?"
|
||||
}
|
||||
],
|
||||
"submitLabel": "生成章节蓝图 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3️⃣:生成设定与蓝图文件(用户回答后执行)
|
||||
|
||||
先写入小说 brief:
|
||||
|
||||
<write_file path="brief.md">
|
||||
# 小说创作 Brief
|
||||
|
||||
## 项目元信息
|
||||
- **项目类型**: ${themeName}
|
||||
- **创作模式**: 引导模式
|
||||
|
||||
## 核心设定
|
||||
- **故事主题**: [用户填写]
|
||||
- **题材类型**: [用户填写]
|
||||
- **世界观/背景**: [用户填写]
|
||||
- **主角设定**: [用户填写]
|
||||
|
||||
## 本章目标
|
||||
- **章节目标**: [用户填写]
|
||||
- **核心冲突**: [用户填写]
|
||||
- **转折点**: [用户填写]
|
||||
- **章末钩子**: [用户填写]
|
||||
</write_file>
|
||||
|
||||
再写入章节蓝图:
|
||||
|
||||
<write_file path="outline.md">
|
||||
# 章节蓝图
|
||||
|
||||
## 场景节拍
|
||||
1. 开场场景:[场景1目标]
|
||||
2. 冲突升级:[场景2目标]
|
||||
3. 关键转折:[场景3目标]
|
||||
4. 收束与钩子:[场景4目标]
|
||||
|
||||
## 角色推进
|
||||
- 主角变化:[本章结束时主角状态变化]
|
||||
- 关系变化:[人物关系变化]
|
||||
</write_file>
|
||||
|
||||
---
|
||||
|
||||
### 步骤 4️⃣:逐场景引导创作
|
||||
|
||||
对每个场景使用引导表单,帮助用户写出正文片段,并逐段保存到 chapter.md。
|
||||
|
||||
每轮引导至少包含:
|
||||
1. 场景目标(发生什么)
|
||||
2. 人物动机(为什么做)
|
||||
3. 感官细节(看到/听到/触到)
|
||||
4. 对话张力(冲突如何体现)
|
||||
|
||||
---
|
||||
|
||||
### 如果用户要求“你直接写完整章”
|
||||
|
||||
请明确提示:
|
||||
> 当前是引导模式,我会优先用提问和场景拆解帮助你共创。
|
||||
> 如果你希望我直接生成完整章节,请切换到「快速模式」。
|
||||
|
||||
${themeGuidance}
|
||||
|
||||
---
|
||||
|
||||
## 🚀 立即开始
|
||||
|
||||
你现在进入了**小说引导创作模式**。
|
||||
|
||||
**请立即返回步骤 1 的小说需求收集表单。**
|
||||
|
||||
记住:第一条回复必须是小说设定表单。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小说快速模式的系统提示词
|
||||
* 小说快速模式收集设定后直接生成章节内容
|
||||
*/
|
||||
function generateNovelFastModePrompt(
|
||||
themeName: string,
|
||||
themeGuidance: string,
|
||||
): string {
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的小说需求收集表单。**
|
||||
|
||||
不要:
|
||||
- ❌ 跳过设定收集直接写章节
|
||||
- ❌ 输出社媒文章式结构(导语/观点/结语)
|
||||
|
||||
必须:
|
||||
- ✅ 收集故事设定后再生成
|
||||
- ✅ 输出小说章节,而非文章
|
||||
- ✅ 使用 <write_file> 标签写入小说文件
|
||||
|
||||
---
|
||||
|
||||
你是一位专业的小说创作助手,当前帮助用户进行「${themeName}」创作。
|
||||
|
||||
快速模式下,你负责基于设定直接生成完整章节草稿。
|
||||
|
||||
${getFileWritingInstructions("novel")}
|
||||
|
||||
${getFormInstructions()}
|
||||
|
||||
## 🔄 工作流程(严格按顺序执行)
|
||||
|
||||
### 步骤 1️⃣:收集小说设定(必须首先执行)
|
||||
|
||||
**你的第一条回复必须是这个表单,无论用户说什么:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "⚡ 小说快速创作 - 设定收集",
|
||||
"description": "填写以下信息,我将直接生成本章初稿。",
|
||||
"fields": [
|
||||
{
|
||||
"id": "storyTheme",
|
||||
"type": "text",
|
||||
"label": "故事主题",
|
||||
"placeholder": "例如:时间循环中的追凶",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "genre",
|
||||
"type": "choice",
|
||||
"label": "题材类型",
|
||||
"options": [
|
||||
{"value": "fantasy", "label": "奇幻"},
|
||||
{"value": "scifi", "label": "科幻"},
|
||||
{"value": "suspense", "label": "悬疑"},
|
||||
{"value": "romance", "label": "言情"},
|
||||
{"value": "historical", "label": "历史"},
|
||||
{"value": "other", "label": "其他"}
|
||||
],
|
||||
"default": "suspense"
|
||||
},
|
||||
{
|
||||
"id": "worldSetting",
|
||||
"type": "text",
|
||||
"label": "世界观/背景",
|
||||
"placeholder": "时间、地点、秩序规则、禁忌设定",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "protagonist",
|
||||
"type": "text",
|
||||
"label": "主角设定",
|
||||
"placeholder": "身份、欲望、恐惧、能力边界",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "conflict",
|
||||
"type": "text",
|
||||
"label": "本章冲突",
|
||||
"placeholder": "主角这章要解决或面对的冲突",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "endingHook",
|
||||
"type": "text",
|
||||
"label": "章末悬念(可选)",
|
||||
"placeholder": "希望在结尾留下什么钩子?"
|
||||
},
|
||||
{
|
||||
"id": "chapterLength",
|
||||
"type": "choice",
|
||||
"label": "本章字数",
|
||||
"options": [
|
||||
{"value": "1500", "label": "约1500字"},
|
||||
{"value": "2000", "label": "约2000字"},
|
||||
{"value": "3000", "label": "约3000字"},
|
||||
{"value": "4000", "label": "约4000字"}
|
||||
],
|
||||
"default": "2000"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始生成章节 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2️⃣:写入章节草稿(表单提交后执行)
|
||||
|
||||
收到表单后,按“开场 → 冲突升级 → 转折 → 章末钩子”结构生成并写入:
|
||||
|
||||
<write_file path="chapter.md">
|
||||
# 第X章 [章节标题]
|
||||
|
||||
[章节正文……]
|
||||
</write_file>
|
||||
|
||||
如用户要求终稿,再生成:
|
||||
|
||||
<write_file path="chapter-final.md">
|
||||
[润色后的章节正文……]
|
||||
</write_file>
|
||||
|
||||
---
|
||||
|
||||
### 生成原则
|
||||
|
||||
1. 角色行为必须符合其动机与能力边界
|
||||
2. 对话必须推进冲突或揭示信息
|
||||
3. 场景描写服务剧情,不堆砌辞藻
|
||||
4. 结尾保留下一章驱动力
|
||||
|
||||
${themeGuidance}
|
||||
|
||||
---
|
||||
|
||||
## 🚀 立即开始
|
||||
|
||||
你现在进入了**小说快速创作模式**。
|
||||
|
||||
**请立即返回步骤 1 的小说需求收集表单。**
|
||||
|
||||
记住:第一条回复必须是小说设定表单。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成引导模式(教练模式)的系统提示词
|
||||
* 借鉴 aster /plan 模式:先问问题收集信息,等待用户确认后再进入下一步
|
||||
@@ -239,6 +640,10 @@ function generateGuidedModePrompt(
|
||||
themeGuidance: string,
|
||||
theme?: ThemeType,
|
||||
): string {
|
||||
if (theme === "novel") {
|
||||
return generateNovelGuidedModePrompt(themeName, themeGuidance);
|
||||
}
|
||||
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的需求收集表单。**
|
||||
@@ -589,6 +994,10 @@ function generateFastModePrompt(
|
||||
themeGuidance: string,
|
||||
theme?: ThemeType,
|
||||
): string {
|
||||
if (theme === "novel") {
|
||||
return generateNovelFastModePrompt(themeName, themeGuidance);
|
||||
}
|
||||
|
||||
return `# 🛑 强制规则 - 必须遵守
|
||||
|
||||
**无论用户说什么,你的第一条回复必须且只能是下面的需求收集表单。**
|
||||
|
||||
@@ -6,17 +6,18 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -29,6 +30,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ContentListItem,
|
||||
@@ -58,6 +65,7 @@ import type {
|
||||
} from "@/types/page";
|
||||
import { toast } from "sonner";
|
||||
import { AgentChatPage } from "@/components/agent";
|
||||
import type { WorkflowProgressSnapshot } from "@/components/agent/chat";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage";
|
||||
import type { CreationMode } from "@/components/content-creator/types";
|
||||
@@ -114,6 +122,23 @@ function parseCreationMode(value: unknown): CreationMode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWorkflowStepStatusLabel(
|
||||
status: WorkflowProgressSnapshot["steps"][number]["status"],
|
||||
): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "进行中";
|
||||
case "completed":
|
||||
return "已完成";
|
||||
case "skipped":
|
||||
return "已跳过";
|
||||
case "error":
|
||||
return "异常";
|
||||
default:
|
||||
return "待开始";
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkbenchPage({
|
||||
onNavigate,
|
||||
projectId: initialProjectId,
|
||||
@@ -122,8 +147,15 @@ export function WorkbenchPage({
|
||||
viewMode: initialViewMode,
|
||||
resetAt,
|
||||
}: WorkbenchPageProps) {
|
||||
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
|
||||
const [showRightSidebar, setShowRightSidebar] = useState(false);
|
||||
const { leftSidebarCollapsed, toggleLeftSidebar, setLeftSidebarCollapsed } =
|
||||
useWorkbenchStore();
|
||||
const [activeRightDrawer, setActiveRightDrawer] = useState<"tools" | null>(
|
||||
null,
|
||||
);
|
||||
const [showChatPanel, setShowChatPanel] = useState(true);
|
||||
const [workflowProgress, setWorkflowProgress] =
|
||||
useState<WorkflowProgressSnapshot | null>(null);
|
||||
const [showWorkflowRail, setShowWorkflowRail] = useState(false);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(
|
||||
initialViewMode ?? (initialContentId ? "workspace" : "project-management"),
|
||||
);
|
||||
@@ -187,10 +219,16 @@ export function WorkbenchPage({
|
||||
);
|
||||
}, [contents, contentQuery]);
|
||||
|
||||
const handleEnterWorkspace = useCallback((contentId: string) => {
|
||||
setSelectedContentId(contentId);
|
||||
setWorkspaceMode("workspace");
|
||||
}, []);
|
||||
const handleEnterWorkspace = useCallback(
|
||||
(contentId: string) => {
|
||||
setSelectedContentId(contentId);
|
||||
setWorkspaceMode("workspace");
|
||||
setShowChatPanel(false);
|
||||
setActiveRightDrawer(null);
|
||||
setLeftSidebarCollapsed(true);
|
||||
},
|
||||
[setLeftSidebarCollapsed],
|
||||
);
|
||||
|
||||
const handleOpenProjectDetail = useCallback(() => {
|
||||
if (!selectedProjectId) {
|
||||
@@ -198,8 +236,7 @@ export function WorkbenchPage({
|
||||
}
|
||||
|
||||
setWorkspaceMode("project-detail");
|
||||
setShowLeftSidebar(true);
|
||||
setShowRightSidebar(false);
|
||||
setActiveRightDrawer(null);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
@@ -388,8 +425,12 @@ export function WorkbenchPage({
|
||||
setSelectedProjectId(initialProjectId ?? null);
|
||||
setSelectedContentId(initialContentId ?? null);
|
||||
setWorkspaceMode(nextMode);
|
||||
setShowLeftSidebar(true);
|
||||
setShowRightSidebar(false);
|
||||
const isWorkspaceMode = nextMode === "workspace";
|
||||
setShowChatPanel(!isWorkspaceMode);
|
||||
if (isWorkspaceMode) {
|
||||
setLeftSidebarCollapsed(true);
|
||||
}
|
||||
setActiveRightDrawer(null);
|
||||
setContents([]);
|
||||
void loadProjects();
|
||||
}, [
|
||||
@@ -398,6 +439,7 @@ export function WorkbenchPage({
|
||||
initialViewMode,
|
||||
loadProjects,
|
||||
resetAt,
|
||||
setLeftSidebarCollapsed,
|
||||
theme,
|
||||
]);
|
||||
|
||||
@@ -512,7 +554,7 @@ export function WorkbenchPage({
|
||||
}
|
||||
|
||||
void loadContents(selectedProjectId);
|
||||
}, [loadContents, selectedProjectId]);
|
||||
}, [loadContents, selectedProjectId, projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedContentId || contentCreationModes[selectedContentId]) {
|
||||
@@ -555,235 +597,311 @@ export function WorkbenchPage({
|
||||
|
||||
const handleBackToProjectManagement = useCallback(() => {
|
||||
setWorkspaceMode("project-management");
|
||||
setShowLeftSidebar(true);
|
||||
setShowRightSidebar(false);
|
||||
setShowChatPanel(true);
|
||||
setActiveRightDrawer(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "workspace") {
|
||||
setWorkflowProgress(null);
|
||||
setShowWorkflowRail(false);
|
||||
}
|
||||
}, [workspaceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowProgress || workflowProgress.steps.length === 0) {
|
||||
setShowWorkflowRail(false);
|
||||
}
|
||||
}, [workflowProgress]);
|
||||
|
||||
// 键盘快捷键: Cmd/Ctrl + B 切换左侧栏
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
||||
event.preventDefault();
|
||||
toggleLeftSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [toggleLeftSidebar]);
|
||||
|
||||
const shouldRenderLeftSidebar =
|
||||
workspaceMode !== "workspace" || showLeftSidebar;
|
||||
workspaceMode !== "workspace" || !leftSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<header className="h-12 border-b px-3 flex items-center gap-2 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleBackHome}
|
||||
title="回到首页"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{workspaceMode === "workspace" && (
|
||||
{workspaceMode !== "workspace" && (
|
||||
<header className="h-12 border-b px-3 flex items-center gap-2 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowLeftSidebar((visible) => !visible)}
|
||||
title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
|
||||
onClick={handleBackHome}
|
||||
title="回到首页"
|
||||
>
|
||||
{showLeftSidebar ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
)}
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{workspaceMode !== "project-management" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleBackToProjectManagement}
|
||||
>
|
||||
项目管理
|
||||
</Button>
|
||||
)}
|
||||
{workspaceMode !== "project-management" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleBackToProjectManagement}
|
||||
>
|
||||
{getProjectTypeLabel(theme)}项目管理
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium ml-2">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</div>
|
||||
{selectedProject && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedProject.name}
|
||||
<div className="text-sm font-medium ml-2">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMode === "workspace" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 ml-auto"
|
||||
onClick={() => setShowRightSidebar((visible) => !visible)}
|
||||
title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
|
||||
>
|
||||
{showRightSidebar ? (
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
{selectedProject && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedProject.name}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{shouldRenderLeftSidebar && (
|
||||
<aside className="w-[260px] min-w-[240px] border-r bg-muted/20 flex flex-col">
|
||||
<div className="px-3 py-3 border-b space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">主题项目管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
void loadProjects();
|
||||
}}
|
||||
disabled={projectsLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
projectsLoading && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleOpenCreateProjectDialog}
|
||||
title="新建项目"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<aside
|
||||
className={cn(
|
||||
"border-r bg-muted/20 flex flex-col transition-all duration-300 ease-out",
|
||||
leftSidebarCollapsed ? "w-16" : "w-[260px] min-w-[240px]",
|
||||
)}
|
||||
>
|
||||
{leftSidebarCollapsed ? (
|
||||
// 图标模式 (折叠状态)
|
||||
<div className="flex flex-col items-center py-3 gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={toggleLeftSidebar}
|
||||
>
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>展开侧边栏 (⌘B)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Input
|
||||
value={projectQuery}
|
||||
onChange={(event) => setProjectQuery(event.target.value)}
|
||||
placeholder="搜索项目..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full border-t" />
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="min-h-0 basis-1/2 border-b flex flex-col">
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
项目
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={toggleLeftSidebar}
|
||||
>
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>项目列表</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={toggleLeftSidebar}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>文稿列表</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
该主题下暂无项目
|
||||
) : (
|
||||
// 完整模式 (展开状态)
|
||||
<>
|
||||
<div className="px-3 py-3 border-b space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
主题项目管理
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedProjectId === project.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={toggleLeftSidebar}
|
||||
title="折叠侧边栏 (⌘B)"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
setSelectedProjectId(project.id);
|
||||
setContentQuery("");
|
||||
void loadProjects();
|
||||
}}
|
||||
disabled={projectsLoading}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{getProjectTypeLabel(project.workspaceType)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 basis-1/2 flex flex-col">
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground flex-1">
|
||||
文稿
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpenCreateContentDialog}
|
||||
disabled={!selectedProjectId}
|
||||
title="新建文稿"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
value={contentQuery}
|
||||
onChange={(event) => setContentQuery(event.target.value)}
|
||||
placeholder="搜索文稿..."
|
||||
className="h-8 text-xs"
|
||||
disabled={!selectedProjectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{contentsLoading ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
文稿加载中...
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
projectsLoading && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleOpenCreateProjectDialog}
|
||||
title="新建项目"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredContents.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
还没有文稿
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={projectQuery}
|
||||
onChange={(event) => setProjectQuery(event.target.value)}
|
||||
placeholder="搜索项目..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="min-h-0 basis-1/2 border-b flex flex-col">
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
项目
|
||||
</div>
|
||||
) : (
|
||||
filteredContents.map((content) => (
|
||||
<button
|
||||
key={content.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedContentId === content.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
该主题下暂无项目
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedProjectId === project.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedProjectId(project.id);
|
||||
setContentQuery("");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{getProjectTypeLabel(project.workspaceType)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
onClick={() => handleEnterWorkspace(content.id)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 basis-1/2 flex flex-col">
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground flex-1">
|
||||
文稿
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpenCreateContentDialog}
|
||||
disabled={!selectedProjectId}
|
||||
title="新建文稿"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{content.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{formatRelativeTime(content.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
value={contentQuery}
|
||||
onChange={(event) =>
|
||||
setContentQuery(event.target.value)
|
||||
}
|
||||
placeholder="搜索文稿..."
|
||||
className="h-8 text-xs"
|
||||
disabled={!selectedProjectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{contentsLoading ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
文稿加载中...
|
||||
</div>
|
||||
) : filteredContents.length === 0 ? (
|
||||
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
|
||||
还没有文稿
|
||||
</div>
|
||||
) : (
|
||||
filteredContents.map((content) => (
|
||||
<button
|
||||
key={content.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2 py-2 transition-colors",
|
||||
"hover:bg-accent",
|
||||
selectedContentId === content.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleEnterWorkspace(content.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{content.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground truncate">
|
||||
{formatRelativeTime(content.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
|
||||
@@ -832,6 +950,9 @@ export function WorkbenchPage({
|
||||
onBack={handleBackToProjectManagement}
|
||||
onNavigateToChat={() => {
|
||||
setWorkspaceMode("workspace");
|
||||
setShowChatPanel(false);
|
||||
setActiveRightDrawer(null);
|
||||
setLeftSidebarCollapsed(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -872,12 +993,16 @@ export function WorkbenchPage({
|
||||
}
|
||||
lockTheme={true}
|
||||
hideHistoryToggle={true}
|
||||
showChatPanel={showChatPanel}
|
||||
onBackToProjectManagement={handleBackToProjectManagement}
|
||||
hideInlineStepProgress={true}
|
||||
onWorkflowProgressChange={setWorkflowProgress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{workspaceMode === "workspace" && showRightSidebar && (
|
||||
{workspaceMode === "workspace" && activeRightDrawer === "tools" && (
|
||||
<aside className="w-[260px] min-w-[260px] border-l bg-muted/10 p-4 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold">主题工具</h3>
|
||||
<Button
|
||||
@@ -902,6 +1027,136 @@ export function WorkbenchPage({
|
||||
</Button>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{workspaceMode === "workspace" && (
|
||||
<aside className="w-14 min-w-14 border-l bg-background/95 flex flex-col items-center py-3 gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
showChatPanel && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => setShowChatPanel((visible) => !visible)}
|
||||
title={showChatPanel ? "隐藏 AI 对话" : "显示 AI 对话"}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{showChatPanel ? "隐藏 AI 对话" : "显示 AI 对话"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
activeRightDrawer === "tools" &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
setActiveRightDrawer((previous) =>
|
||||
previous === "tools" ? null : "tools",
|
||||
)
|
||||
}
|
||||
title={
|
||||
activeRightDrawer === "tools"
|
||||
? "收起主题工具"
|
||||
: "展开主题工具"
|
||||
}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>
|
||||
{activeRightDrawer === "tools"
|
||||
? "收起主题工具"
|
||||
: "展开主题工具"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{workflowProgress && workflowProgress.steps.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
showWorkflowRail && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
setShowWorkflowRail((previous) => !previous)
|
||||
}
|
||||
title={showWorkflowRail ? "收起流程步骤" : "展开流程步骤"}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{showWorkflowRail ? "收起流程步骤" : "展开流程步骤"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{workflowProgress &&
|
||||
workflowProgress.steps.length > 0 &&
|
||||
showWorkflowRail && (
|
||||
<div className="overflow-hidden max-h-80 opacity-100 pointer-events-auto transition-all duration-200">
|
||||
<div className="w-8 border-t my-1" />
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{workflowProgress.steps.map((step, index) => {
|
||||
const isCurrent =
|
||||
index === workflowProgress.currentIndex;
|
||||
const isCompleted =
|
||||
step.status === "completed" ||
|
||||
step.status === "skipped";
|
||||
return (
|
||||
<Tooltip key={step.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full border text-[11px] font-medium transition-colors",
|
||||
isCurrent &&
|
||||
"border-primary bg-primary/10 text-primary",
|
||||
!isCurrent &&
|
||||
isCompleted &&
|
||||
"border-primary/40 bg-primary/5 text-primary",
|
||||
!isCurrent &&
|
||||
!isCompleted &&
|
||||
"border-border bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isCompleted ? "✓" : index + 1}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{step.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{isCurrent
|
||||
? "当前步骤"
|
||||
: getWorkflowStepStatusLabel(step.status)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+23
-4
@@ -823,6 +823,23 @@ export async function confirmAsterAction(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Aster Agent elicitation 响应
|
||||
*/
|
||||
export async function submitAsterElicitationResponse(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
userData: unknown,
|
||||
): Promise<void> {
|
||||
return await safeInvoke("aster_agent_submit_elicitation_response", {
|
||||
sessionId,
|
||||
request: {
|
||||
request_id: requestId,
|
||||
user_data: userData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Terminal Tool API (终端命令执行)
|
||||
// ============================================================
|
||||
@@ -961,9 +978,11 @@ export interface PermissionResponse {
|
||||
export async function sendPermissionResponse(
|
||||
response: PermissionResponse,
|
||||
): Promise<void> {
|
||||
return await safeInvoke("agent_permission_response", {
|
||||
requestId: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
return await safeInvoke("aster_agent_confirm", {
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ const defaultMocks: Record<string, any> = {
|
||||
aster_session_list: () => [],
|
||||
aster_session_get: () => ({ id: "mock", messages: [] }),
|
||||
aster_agent_confirm: () => ({}),
|
||||
aster_agent_submit_elicitation_response: () => ({}),
|
||||
|
||||
// 终端相关
|
||||
create_terminal_session: () => ({ uuid: "mock-terminal-uuid" }),
|
||||
|
||||
+72
-10
@@ -74,10 +74,15 @@ export interface SessionInfo {
|
||||
/** 权限确认请求 */
|
||||
export interface ActionRequired {
|
||||
requestId: string;
|
||||
actionType: "tool_confirmation" | "ask_user" | "permission_request";
|
||||
actionType:
|
||||
| "tool_confirmation"
|
||||
| "ask_user"
|
||||
| "elicitation"
|
||||
| "permission_request";
|
||||
toolName?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
question?: string;
|
||||
prompt?: string;
|
||||
requestedSchema?: Record<string, unknown>;
|
||||
options?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -90,6 +95,8 @@ export interface ConfirmResponse {
|
||||
requestId: string;
|
||||
confirmed: boolean;
|
||||
response?: string;
|
||||
actionType?: ActionRequired["actionType"];
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
// ============ Tauri 事件类型 ============
|
||||
@@ -302,13 +309,53 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// 确认权限请求
|
||||
confirmAction: async (response: ConfirmResponse) => {
|
||||
try {
|
||||
await invoke("aster_agent_confirm", {
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
},
|
||||
});
|
||||
const state = get();
|
||||
const actionType =
|
||||
response.actionType ||
|
||||
state.pendingActions.find((a) => a.requestId === response.requestId)
|
||||
?.actionType;
|
||||
|
||||
if (actionType === "elicitation" || actionType === "ask_user") {
|
||||
if (!state.currentSessionId) {
|
||||
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
|
||||
}
|
||||
|
||||
let userData: unknown;
|
||||
if (!response.confirmed) {
|
||||
userData = "";
|
||||
} else if (response.userData !== undefined) {
|
||||
userData = response.userData;
|
||||
} else if (response.response !== undefined) {
|
||||
const rawResponse = response.response.trim();
|
||||
if (!rawResponse) {
|
||||
userData = "";
|
||||
} else {
|
||||
try {
|
||||
userData = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
userData = rawResponse;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userData = "";
|
||||
}
|
||||
|
||||
await invoke("aster_agent_submit_elicitation_response", {
|
||||
sessionId: state.currentSessionId,
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
user_data: userData,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await invoke("aster_agent_confirm", {
|
||||
request: {
|
||||
request_id: response.requestId,
|
||||
confirmed: response.confirmed,
|
||||
response: response.response,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 移除已处理的请求
|
||||
set((s) => ({
|
||||
@@ -580,7 +627,22 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
{
|
||||
requestId: event.request_id,
|
||||
actionType: event.action_type as ActionRequired["actionType"],
|
||||
...event.data,
|
||||
toolName: event.data.tool_name as string | undefined,
|
||||
arguments: event.data.arguments as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
prompt:
|
||||
(event.data.prompt as string | undefined) ||
|
||||
(event.data.message as string | undefined),
|
||||
requestedSchema: event.data.requested_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
options: event.data.options as
|
||||
| Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
}>
|
||||
| undefined,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file useWorkbenchStore.ts
|
||||
* @description Workbench 页面的 Zustand 状态管理 Store
|
||||
* @module stores/useWorkbenchStore
|
||||
*
|
||||
* 管理 Workbench 页面的 UI 状态,包括侧边栏折叠状态
|
||||
* 使用 persist 中间件持久化到 localStorage
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* Workbench Store 状态接口
|
||||
*/
|
||||
export interface WorkbenchState {
|
||||
/** 左侧栏是否折叠 */
|
||||
leftSidebarCollapsed: boolean;
|
||||
|
||||
/** 切换左侧栏折叠状态 */
|
||||
toggleLeftSidebar: () => void;
|
||||
|
||||
/** 设置左侧栏折叠状态 */
|
||||
setLeftSidebarCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始状态
|
||||
*/
|
||||
const initialState = {
|
||||
leftSidebarCollapsed: true, // 默认折叠,给画布更多空间
|
||||
};
|
||||
|
||||
/**
|
||||
* Workbench Zustand Store
|
||||
*
|
||||
* 使用 persist 中间件持久化 UI 状态到 localStorage
|
||||
*/
|
||||
export const useWorkbenchStore = create<WorkbenchState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
toggleLeftSidebar: () => {
|
||||
set((state) => ({
|
||||
leftSidebarCollapsed: !state.leftSidebarCollapsed,
|
||||
}));
|
||||
},
|
||||
|
||||
setLeftSidebarCollapsed: (collapsed: boolean) => {
|
||||
set({ leftSidebarCollapsed: collapsed });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "workbench-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user