diff --git a/package.json b/package.json index 162c44cc7..329c76b39 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.64.0", + "version": "0.65.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 152d0a16d..5e1061e9c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8b7c1dee5..1fa03e874 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/crates/agent/src/ask_bridge.rs b/src-tauri/crates/agent/src/ask_bridge.rs new file mode 100644 index 000000000..264e0b62c --- /dev/null +++ b/src-tauri/crates/agent/src/ask_bridge.rs @@ -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>| { + 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 = 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 { + 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()), + } +} diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 177a69ec3..4ddfcf296 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -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(); diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index 9f03f27f9..0b123d581 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -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() { diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 49e816349..c7f857a4f 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -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, diff --git a/src-tauri/crates/agent/src/lsp_bridge.rs b/src-tauri/crates/agent/src/lsp_bridge.rs new file mode 100644 index 000000000..3ca39e8d6 --- /dev/null +++ b/src-tauri/crates/agent/src/lsp_bridge.rs @@ -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| { + 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, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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| 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), + ), + ) +} diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs index d76cef252..bc52937b6 100644 --- a/src-tauri/crates/mcp/src/manager.rs +++ b/src-tauri/crates/mcp/src/manager.rs @@ -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(); diff --git a/src-tauri/crates/scheduler/src/batch_dao.rs b/src-tauri/crates/scheduler/src/batch_dao.rs index 5551e1785..d1b40eff8 100644 --- a/src-tauri/crates/scheduler/src/batch_dao.rs +++ b/src-tauri/crates/scheduler/src/batch_dao.rs @@ -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(); diff --git a/src-tauri/crates/scheduler/src/scheduler.rs b/src-tauri/crates/scheduler/src/scheduler.rs index a2e776de3..7b4251cfe 100644 --- a/src-tauri/crates/scheduler/src/scheduler.rs +++ b/src-tauri/crates/scheduler/src/scheduler.rs @@ -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}; diff --git a/src-tauri/crates/server/src/handlers/api.rs b/src-tauri/crates/server/src/handlers/api.rs index 368422940..7d04e0764 100644 --- a/src-tauri/crates/server/src/handlers/api.rs +++ b/src-tauri/crates/server/src/handlers/api.rs @@ -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 } })), ) diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs index cfc1b489c..881a72f92 100644 --- a/src-tauri/crates/server/src/lib.rs +++ b/src-tauri/crates/server/src/lib.rs @@ -442,6 +442,8 @@ pub struct AppState { pub injection_enabled: Arc>, /// 请求处理器 pub processor: Arc, + /// 是否允许自动降级/切换 Provider(来自配置 retry.auto_switch_provider) + pub allow_provider_fallback: bool, /// WebSocket 连接管理器 pub ws_manager: Arc, /// 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(), diff --git a/src-tauri/resources/models/aliases/codex.json b/src-tauri/resources/models/aliases/codex.json index c3bf639ce..a40ce2812 100644 --- a/src-tauri/resources/models/aliases/codex.json +++ b/src-tauri/resources/models/aliases/codex.json @@ -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" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/opencode.json b/src-tauri/resources/models/providers/opencode.json index 6ce95497b..5e0281d70 100644 --- a/src-tauri/resources/models/providers/opencode.json +++ b/src-tauri/resources/models/providers/opencode.json @@ -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" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/openrouter.json b/src-tauri/resources/models/providers/openrouter.json index f1ed21b5e..c97380631 100644 --- a/src-tauri/resources/models/providers/openrouter.json +++ b/src-tauri/resources/models/providers/openrouter.json @@ -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" -} +} \ No newline at end of file diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index e8f78e68c..42880e575 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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, diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index ab7b339b3..30a1aa9bf 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -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 { + 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 diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index ebef14ba5..d816a4ef7 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -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 = ({ @@ -21,11 +36,13 @@ export const ChatNavbar: React.FC = ({ onToggleHistory, showHistoryToggle = true, onToggleFullscreen: _onToggleFullscreen, + onBackToProjectManagement, onToggleSettings, onBackHome, projectId = null, onProjectChange, workspaceType, + novelCanvasControls = null, }) => { return ( @@ -51,6 +68,56 @@ export const ChatNavbar: React.FC = ({ )} + {onBackToProjectManagement && ( + + )} + {novelCanvasControls && ( + <> +
+ + + + + )}
diff --git a/src/components/agent/chat/components/DecisionPanel.test.tsx b/src/components/agent/chat/components/DecisionPanel.test.tsx new file mode 100644 index 000000000..844cc117d --- /dev/null +++ b/src/components/agent/chat/components/DecisionPanel.test.tsx @@ -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 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(); + }); + + 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( + `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(""); + }); +}); diff --git a/src/components/agent/chat/components/DecisionPanel.tsx b/src/components/agent/chat/components/DecisionPanel.tsx index 9c91b558e..e7c75accc 100644 --- a/src/components/agent/chat/components/DecisionPanel.tsx +++ b/src/components/agent/chat/components/DecisionPanel.tsx @@ -66,18 +66,53 @@ function formatArguments(args?: Record): string { } } +/** 从 requested_schema 中提取 answer.enum 选项 */ +function extractElicitationOptions( + requestedSchema?: Record, +): string[] { + if (!requestedSchema) return []; + const properties = requestedSchema.properties as + | Record + | undefined; + const answer = properties?.answer as Record | 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 | undefined { + if (!requestedSchema) return undefined; + const properties = requestedSchema.properties as + | Record + | undefined; + const answer = properties?.answer as Record | 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 >({}); const [otherInputs, setOtherInputs] = useState>({}); + 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 = {}; + + 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 ( + + + + + 需要你提供信息 + + + +

+ {request.prompt || "请提供继续执行所需的信息"} +

+ + {elicitationDescription && ( +

+ {elicitationDescription} +

+ )} + + {elicitationOptions.length > 0 && ( +
+ {elicitationOptions.map((option) => { + const isSelected = elicitationAnswer === option; + return ( + + ); + })} +
+ )} + +
+ + setElicitationAnswer(e.target.value)} + /> +
+ +
+ + setElicitationOther(e.target.value)} + /> +
+ +
+ + +
+
+
+ ); + } + // 渲染用户问题面板 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; } diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 1449412dd..eb52460ad 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -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("权限确认响应失败"); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx new file mode 100644 index 000000000..443b759ee --- /dev/null +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -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; + unmount: () => void; +} + +function mountHook(workspaceId = "ws-test"): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useAsterAgentChat({ workspaceId }); + return null; + } + + act(() => { + root.render(); + }); + + 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(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index 36262020a..b3363023c 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -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; - question?: string; + prompt?: string; + requestedSchema?: Record; 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 | undefined) || {}; const actionData: ActionRequired = { requestId: rawEvent.request_id as string, - actionType: rawEvent.action_type as string, - toolName: (rawEvent.data as Record) - ?.tool_name as string, - arguments: (rawEvent.data as Record) - ?.arguments as Record, - question: (rawEvent.data as Record) - ?.question as string, + actionType: + rawEvent.action_type as ActionRequired["actionType"], + toolName: actionPayload.tool_name as string | undefined, + arguments: actionPayload.arguments as + | Record + | undefined, + prompt: + (actionPayload.prompt as string | undefined) || + (actionPayload.message as string | undefined), + requestedSchema: actionPayload.requested_schema as + | Record + | 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(() => { diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 9906dd73c..9bbc3c366 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -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(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(""); + 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 = ( - {/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */} - {isContentCreationMode && hasMessages && steps.length > 0 && ( - - )} + + {/* 步骤进度条 - 仅在内容创作模式且有消息时显示 */} + {!hideInlineStepProgress && + isContentCreationMode && + hasMessages && + steps.length > 0 && ( + + )} - {showChatLayout ? ( - - - - ) : ( - { - 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 && ( - <> - + + + ) : ( + { + 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 && ( + <> + setTaskFilesExpanded(!taskFilesExpanded)} + onTaskFileClick={handleTaskFileClick} + characters={projectMemory?.characters || []} + onSelectCharacter={(character) => { + setMentionedCharacters((prev) => { + // 避免重复添加 + if (prev.find((c) => c.id === character.id)) return prev; + return [...prev, character]; + }); + }} + /> + + )} + ); @@ -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 ( - {showSidebar && ( + {showChatPanel && showSidebar && ( {}} + 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 + } /> {/* 同步状态指示器 */} diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 0e05fd2ff..43f2d609f 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -63,6 +63,10 @@ export interface ConfirmResponse { confirmed: boolean; /** 响应内容(用户输入或选择的答案) */ response?: string; + /** 操作类型(用于前端分流) */ + actionType?: ActionRequired["actionType"]; + /** 原始用户数据(用于 elicitation) */ + userData?: unknown; } export interface Message { diff --git a/src/components/content-creator/canvas/CanvasFactory.tsx b/src/components/content-creator/canvas/CanvasFactory.tsx index 10277d33f..f40c02cb9 100644 --- a/src/components/content-creator/canvas/CanvasFactory.tsx +++ b/src/components/content-creator/canvas/CanvasFactory.tsx @@ -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 = 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 = memo( state={state} onStateChange={onStateChange as (s: NovelCanvasState) => void} onClose={onClose} + useExternalToolbar={novelControls?.useExternalToolbar} + chapterListCollapsed={novelControls?.chapterListCollapsed} + onChapterListCollapsedChange={ + novelControls?.onChapterListCollapsedChange + } /> ); } diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx index ab00974d9..0c261c803 100644 --- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx +++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx @@ -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 = memo( return ( - + + - - {state.isEditing ? ( - - ) : ( - + {state.isEditing ? ( + + ) : ( + + )} + + + {!state.isEditing && ( + )} - - - {!state.isEditing && ( - - )} + {toastMessage} diff --git a/src/components/content-creator/canvas/document/DocumentToolbar.tsx b/src/components/content-creator/canvas/document/DocumentToolbar.tsx index 018330955..687663bfd 100644 --- a/src/components/content-creator/canvas/document/DocumentToolbar.tsx +++ b/src/components/content-creator/canvas/document/DocumentToolbar.tsx @@ -174,7 +174,7 @@ export const DocumentToolbar: React.FC = memo( return ( - 📄 文档预览 + 文档 = { }; 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 = memo( return ( - + + - - - - -
- {state.viewMode === "lyrics" && "🎤 歌词"} - {state.viewMode === "numbered" && "🎼 简谱"} - {state.viewMode === "guitar" && "🎸 吉他谱"} - {state.viewMode === "piano" && "🎹 钢琴谱"} - {isStreaming && ( - - 生成中... - - )} -
- {state.viewMode === "lyrics" && ( - - )} -
- {renderContent()} -
-
-
+ {state.viewMode === "lyrics" && "🎤 歌词"} + {state.viewMode === "numbered" && "🎼 简谱"} + {state.viewMode === "guitar" && "🎸 吉他谱"} + {state.viewMode === "piano" && "🎹 钢琴谱"} + {isStreaming && ( + + 生成中... + + )} +
+ {state.viewMode === "lyrics" && ( + + )} + + {renderContent()} + + + - - - 🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM - - - {stats.totalSections} 段 | {stats.totalLines} 行 |{" "} - {stats.totalChars} 字 - - + + + 🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM + + + {stats.totalSections} 段 | {stats.totalLines} 行 |{" "} + {stats.totalChars} 字 + + - {toastMessage} + {toastMessage} + ); }, diff --git a/src/components/content-creator/canvas/music/MusicToolbar.tsx b/src/components/content-creator/canvas/music/MusicToolbar.tsx index 9289c8116..69f8d3e3c 100644 --- a/src/components/content-creator/canvas/music/MusicToolbar.tsx +++ b/src/components/content-creator/canvas/music/MusicToolbar.tsx @@ -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 = memo( return ( + 音乐 {spec.title} {spec.key} | {spec.tempo} BPM diff --git a/src/components/content-creator/canvas/novel/NovelCanvas.tsx b/src/components/content-creator/canvas/novel/NovelCanvas.tsx index 59db90dd9..efcc84f6b 100644 --- a/src/components/content-creator/canvas/novel/NovelCanvas.tsx +++ b/src/components/content-creator/canvas/novel/NovelCanvas.tsx @@ -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 = 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 = memo( chapters: [...state.chapters, newChapter], currentChapterId: newChapter.id, }); + setEditorKey((prev) => prev + 1); }, [state, onStateChange]); const handleUpdateChapter = useCallback( - (updates: Partial) => { + (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 = 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 = memo( return ( -
- - 小说编辑器 - - {state.chapters.length} 章 · {totalWords} 字 - - - -
+ + {!useExternalToolbar && ( +
+
+ + +
+
+ )} + + {!isChapterListCollapsed && ( + + + 章节 + {!useExternalToolbar && ( + + )} + + + + {state.chapters.map((chapter) => ( + handleChapterSelect(chapter.id)} + > + + {chapter.status === "completed" ? ( + + ) : ( + + )} + {chapter.title} + + {chapter.wordCount} 字 + + ))} + + + + + 总章节 + {state.chapters.length} + + + 已完成 + {completedCount} + + + 总字数 + {totalWords.toLocaleString()} + + + + )} - - - - 章节 - - - - - {state.chapters.map((chapter) => ( - handleChapterSelect(chapter.id)} - > - - {chapter.status === "completed" ? ( - - ) : ( - - )} - {chapter.title} - - {chapter.wordCount} 字 - - ))} - - - - - - {currentChapter && ( - <> - - - handleUpdateChapter({ title: e.target.value }) - } - placeholder="章节标题" - className="text-lg font-medium" - /> + + {!useExternalToolbar && isChapterListCollapsed && ( +
- - + + +
+ )} + {currentChapter && ( - - handleUpdateChapter({ content: e.target.value }) - } - placeholder="开始写作..." + {}} /> - - )} + )} - {!currentChapter && ( - - - 请先选择章节,或在左侧新建章节开始创作 - - - )} -
-
- - - - {completedCount}/{state.chapters.length} 章完成 - - 总字数:{totalWords.toLocaleString()} - + {!currentChapter && ( + + + 请先选择章节,或在左侧新建章节开始创作 + + + )} + +
+
); }, diff --git a/src/components/content-creator/canvas/novel/types.ts b/src/components/content-creator/canvas/novel/types.ts index c361e5d6d..6081b11c8 100644 --- a/src/components/content-creator/canvas/novel/types.ts +++ b/src/components/content-creator/canvas/novel/types.ts @@ -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(/[\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, 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) || {}, 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 = ""; diff --git a/src/components/content-creator/canvas/poster/PosterCanvas.tsx b/src/components/content-creator/canvas/poster/PosterCanvas.tsx index 750021cc0..e57810568 100644 --- a/src/components/content-creator/canvas/poster/PosterCanvas.tsx +++ b/src/components/content-creator/canvas/poster/PosterCanvas.tsx @@ -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 = memo( return ( - + + - - - - - + + + + + + + + {/* 图层面板 */} + {showLayerPanel && ( + { + // 选择图层对应的元素 + 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} /> - - + )} + - {/* 图层面板 */} - {showLayerPanel && ( - { - // 选择图层对应的元素 - 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} - /> - )} - + - + 0} + gridSnapEnabled={gridSnapEnabled} + onAlign={handleAlign} + onToggleGridSnap={toggleGridSnap} + /> - 0} - gridSnapEnabled={gridSnapEnabled} - onAlign={handleAlign} - onToggleGridSnap={toggleGridSnap} - /> - - {/* 隐藏的文件输入 */} - + {/* 隐藏的文件输入 */} + + ); }, diff --git a/src/components/content-creator/canvas/poster/PosterToolbar.tsx b/src/components/content-creator/canvas/poster/PosterToolbar.tsx index 4e5b7a6e5..35e43e0f6 100644 --- a/src/components/content-creator/canvas/poster/PosterToolbar.tsx +++ b/src/components/content-creator/canvas/poster/PosterToolbar.tsx @@ -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 = memo( return ( - {/* 左侧:缩放控制 */} + {/* 左侧:主题标签和缩放控制 */} + 海报 + - + +
+ 剧本 + +
- - - - 场景 - - - - {state.scenes.map((scene) => ( - handleSceneSelect(scene.id)} - > - 第{scene.number}场 - - {scene.location}({scene.time}) - - - ))} - - + + + + 场景 + + + + {state.scenes.map((scene) => ( + handleSceneSelect(scene.id)} + > + 第{scene.number}场 + + {scene.location}({scene.time}) + + + ))} + + - - {currentScene && ( - <> - - - handleUpdateScene({ location: e.target.value }) - } - placeholder="场景地点" - className="w-40" - /> - - handleUpdateScene({ time: e.target.value }) - } - placeholder="时间" - className="w-20" - /> -