release: bump version to 0.65.0

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