diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 80e101393..b84fbce80 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -26,6 +26,7 @@ use aster::agents::{Agent, SessionConfig}; use aster::model::ModelConfig; #[cfg(test)] use aster::skills::{global_registry, load_skills_from_directory, SkillSource}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::sync::RwLock; @@ -87,6 +88,50 @@ impl AsterAgentState { } } + fn resolve_aster_path_root() -> Result { + if let Ok(raw) = std::env::var("ASTER_PATH_ROOT") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + let home_dir = dirs::home_dir().ok_or_else(|| "无法获取用户目录".to_string())?; + Ok(home_dir.join(".proxycast").join("aster")) + } + + fn ensure_aster_runtime_dirs() -> Result { + let root = Self::resolve_aster_path_root()?; + let root_string = root.to_string_lossy().to_string(); + + if std::env::var("ASTER_PATH_ROOT") + .ok() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + std::env::set_var("ASTER_PATH_ROOT", &root_string); + } + + let dirs = [ + root.join("config"), + root.join("data"), + root.join("state"), + root.join("state").join("logs"), + ]; + + for dir in dirs { + std::fs::create_dir_all(&dir).map_err(|e| { + format!( + "初始化 Aster 运行目录失败: {} ({})", + dir.to_string_lossy(), + e + ) + })?; + } + + Ok(root) + } + /// 初始化 Agent(带数据库连接) /// /// 创建 Agent 并注入 ProxyCastSessionStore,确保消息存储到 ProxyCast 数据库。 @@ -98,6 +143,12 @@ impl AsterAgentState { /// # 参数 /// - `db`: 数据库连接,用于创建 SessionStore pub async fn init_agent_with_db(&self, db: &DbConnection) -> Result<(), String> { + let runtime_root = Self::ensure_aster_runtime_dirs()?; + tracing::info!( + "[AsterAgent] Aster 运行目录已准备: {}", + runtime_root.to_string_lossy() + ); + // 快速路径:检查缓存 if self.initialized_cache.load(Ordering::Relaxed) { return Ok(()); diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 549556007..4d1f58a86 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -20,6 +20,20 @@ fn push_non_empty(target: &mut Vec, value: Option<&str>) { } } +fn enhance_execution_error_text(raw: &str) -> String { + if !raw.contains("Execution error: No such file or directory (os error 2)") { + return raw.to_string(); + } + + if raw.contains("排查建议:") { + return raw.to_string(); + } + + format!( + "{raw}\n\n排查建议:\n1) 检查工作区目录是否仍然存在(目录被移动/删除会触发该错误)。\n2) 若使用本地 CLI Provider,请确认对应命令已安装且在 PATH 中。\n3) 重启应用后重试;若仍失败,请复制该错误并附上系统信息。" + ) +} + fn dedupe_preserve_order(items: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut deduped = Vec::new(); @@ -422,7 +436,7 @@ fn convert_message(message: Message) -> Vec { match content { MessageContent::Text(text_content) => { events.push(TauriAgentEvent::TextDelta { - text: text_content.text.clone(), + text: enhance_execution_error_text(&text_content.text), }); } MessageContent::Thinking(thinking) => { diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index f4a2c2c75..b4a204b16 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -12,7 +12,9 @@ use aster::model::ModelConfig; use aster::recipe::Recipe; use aster::session::extension_data::ExtensionData; use aster::session::{ - ChatHistoryMatch, Session, SessionInsights, SessionStore, SessionType, TokenStatsUpdate, + ChatHistoryMatch, CommitOptions, CommitReport, MemoryCategory, MemoryHealth, MemoryRecord, + MemorySearchResult, MemoryStats, Session, SessionInsights, SessionStore, SessionType, + TokenStatsUpdate, }; use async_trait::async_trait; use chrono::Utc; @@ -664,6 +666,48 @@ impl SessionStore for ProxyCastSessionStore { Ok(matches) } + + async fn commit_session(&self, id: &str, _options: CommitOptions) -> Result { + Ok(CommitReport { + session_id: id.to_string(), + messages_scanned: 0, + memories_created: 0, + memories_merged: 0, + source_start_ts: None, + source_end_ts: None, + warnings: vec!["ProxyCastSessionStore: memory commit skipped".to_string()], + }) + } + + async fn search_memories( + &self, + _query: &str, + _limit: Option, + _session_scope: Option<&str>, + _categories: Option>, + ) -> Result> { + Ok(vec![]) + } + + async fn retrieve_context_memories( + &self, + _session_id: &str, + _query: &str, + _limit: usize, + ) -> Result> { + Ok(vec![]) + } + + async fn memory_stats(&self) -> Result { + Ok(MemoryStats::default()) + } + + async fn memory_health(&self) -> Result { + Ok(MemoryHealth { + healthy: true, + message: "ProxyCastSessionStore: memory subsystem disabled".to_string(), + }) + } } // ============================================================================ diff --git a/src-tauri/src/app/commands/logs.rs b/src-tauri/src/app/commands/logs.rs index 5d8d6c6f8..51e29b386 100644 --- a/src-tauri/src/app/commands/logs.rs +++ b/src-tauri/src/app/commands/logs.rs @@ -4,6 +4,7 @@ use crate::app::types::LogState; use crate::logger; +use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -38,6 +39,59 @@ pub async fn clear_logs(logs: tauri::State<'_, LogState>) -> Result<(), String> Ok(()) } +fn parse_persisted_log_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some((timestamp, rest)) = trimmed.split_once(" [") { + if let Some((level, message)) = rest.split_once("] ") { + return Some(logger::LogEntry { + timestamp: timestamp.trim().to_string(), + level: level.trim().to_lowercase(), + message: message.trim().to_string(), + }); + } + } + + Some(logger::LogEntry { + timestamp: Utc::now().to_rfc3339(), + level: "info".to_string(), + message: trimmed.to_string(), + }) +} + +/// 获取持久化日志文件尾部(用于崩溃后恢复诊断) +#[tauri::command] +pub async fn get_persisted_logs_tail( + logs: tauri::State<'_, LogState>, + lines: Option, +) -> Result, String> { + let safe_limit = lines.unwrap_or(200).clamp(20, 1000); + let log_file_path = logs.read().await.get_log_file_path(); + + let Some(path) = log_file_path else { + return Ok(Vec::new()); + }; + + let content = std::fs::read_to_string(&path).map_err(|e| format!("读取持久化日志失败: {e}"))?; + + if content.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut parsed: Vec = content + .lines() + .rev() + .take(safe_limit) + .filter_map(parse_persisted_log_line) + .collect(); + + parsed.reverse(); + Ok(parsed) +} + /// 写入前端异常到本地日志并同步到崩溃上报后端 #[tauri::command] pub async fn report_frontend_crash( diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index a5d7cbd0d..a63d2fd52 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -767,6 +767,7 @@ pub fn run() { app_commands::set_claude_custom_config, // Log commands (from app::commands) app_commands::get_logs, + app_commands::get_persisted_logs_tail, app_commands::clear_logs, app_commands::report_frontend_crash, // API test commands (from app::commands) @@ -1272,6 +1273,8 @@ pub fn run() { commands::workspace_cmd::workspace_delete, commands::workspace_cmd::workspace_set_default, commands::workspace_cmd::workspace_get_default, + commands::workspace_cmd::workspace_ensure_ready, + commands::workspace_cmd::workspace_ensure_default_ready, commands::workspace_cmd::workspace_get_by_path, commands::workspace_cmd::workspace_get_projects_root, commands::workspace_cmd::workspace_resolve_project_path, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index b839cb1ac..45221f506 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -10,6 +10,7 @@ use crate::database::dao::agent::AgentDao; use crate::database::DbConnection; use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; +use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; use crate::workspace::WorkspaceManager; use crate::AppState; use serde::{Deserialize, Serialize}; @@ -195,7 +196,19 @@ pub async fn agent_create_session( .get(&workspace_id) .map_err(|e| format!("读取 workspace 失败: {e}"))? .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; - let workspace_root = workspace.root_path.to_string_lossy().to_string(); + let ensured = ensure_workspace_ready_with_auto_relocate(&workspace_manager, &workspace)?; + if ensured.repaired { + tracing::warn!( + "[Agent] Workspace 路径异常已自动修复: {}{}", + ensured.root_path.to_string_lossy(), + if ensured.relocated { + "(已迁移)" + } else { + "" + } + ); + } + let workspace_root = ensured.root_path.to_string_lossy().to_string(); // 初始化 Agent(使用带数据库的版本) agent_state.init_agent_with_db(&db).await?; diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index e7d322341..89e4da330 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -20,7 +20,9 @@ use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOp use crate::services::heartbeat_service::HeartbeatServiceState; use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; +use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; use crate::workspace::WorkspaceManager; +use crate::LogState; use aster::agents::extension::{Envs, ExtensionConfig}; use aster::agents::{Agent, AgentEvent}; use aster::chrome_mcp::get_chrome_mcp_tools; @@ -56,6 +58,7 @@ const WORKSPACE_SANDBOX_ENABLED_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_ENABLED const WORKSPACE_SANDBOX_STRICT_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_STRICT"; const WORKSPACE_SANDBOX_NOTIFY_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK"; const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback"; +const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created"; static SHARED_TASK_MANAGER: OnceLock> = OnceLock::new(); @@ -372,6 +375,29 @@ struct ReplyAttemptError { emitted_any: bool, } +fn extract_inline_agent_provider_error(message: &Message) -> Option { + let text = message.as_concat_text(); + if !text.contains("Ran into this error:") { + return None; + } + if !text.contains("Please retry if you think this is a transient or recoverable error.") { + return None; + } + + let after_prefix = text.split_once("Ran into this error:")?.1.trim(); + let detail = after_prefix + .split_once("\n\nPlease retry if you think this is a transient or recoverable error.") + .map(|(left, _)| left.trim()) + .unwrap_or(after_prefix) + .trim_end_matches('.'); + + if detail.is_empty() { + return Some("Agent provider execution failed".to_string()); + } + + Some(format!("Agent provider execution failed: {detail}")) +} + fn should_use_code_orchestrated_for_message(message: &str) -> bool { let lowered = message.to_lowercase(); let keywords = [ @@ -427,12 +453,23 @@ async fn stream_reply_once( match event_result { Ok(agent_event) => { emitted_any = true; + let inline_provider_error = match &agent_event { + AgentEvent::Message(message) => extract_inline_agent_provider_error(message), + _ => None, + }; let tauri_events = convert_agent_event(agent_event); for tauri_event in tauri_events { if let Err(e) = app.emit(event_name, &tauri_event) { tracing::error!("[AsterAgent] 发送事件失败: {}", e); } } + if let Some(message) = inline_provider_error { + tracing::warn!("[AsterAgent] 捕获到消息级 Provider 错误: {}", message); + return Err(ReplyAttemptError { + message, + emitted_any: true, + }); + } } Err(e) => { return Err(ReplyAttemptError { @@ -1556,6 +1593,7 @@ pub async fn aster_agent_chat_stream( app: AppHandle, state: State<'_, AsterAgentState>, db: State<'_, DbConnection>, + logs: State<'_, LogState>, config_manager: State<'_, GlobalConfigManagerState>, mcp_manager: State<'_, McpManagerState>, heartbeat_state: State<'_, HeartbeatServiceState>, @@ -1592,15 +1630,59 @@ pub async fn aster_agent_chat_stream( let workspace_id = request.workspace_id.trim().to_string(); if workspace_id.is_empty() { - return Err("workspace_id 必填,请先选择项目工作区".to_string()); + let message = "workspace_id 必填,请先选择项目工作区".to_string(); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); } let manager = WorkspaceManager::new(db.inner().clone()); - let workspace = manager - .get(&workspace_id) - .map_err(|e| format!("读取 workspace 失败: {e}"))? - .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; - let workspace_root = workspace.root_path.to_string_lossy().to_string(); + let workspace = match manager.get(&workspace_id) { + Ok(Some(workspace)) => workspace, + Ok(None) => { + let message = format!("Workspace 不存在: {workspace_id}"); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + Err(error) => { + let message = format!("读取 workspace 失败: {error}"); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) { + Ok(result) => result, + Err(message) => { + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + let workspace_root = ensured.root_path.to_string_lossy().to_string(); + if ensured.repaired { + let warning_message = ensured.warning.unwrap_or_else(|| { + format!( + "检测到工作区目录缺失,已自动创建并继续执行: {}", + workspace_root + ) + }); + logs.write() + .await + .add("warn", &format!("[AsterAgent] {}", warning_message)); + let warning_event = TauriAgentEvent::Warning { + code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()), + message: warning_message, + }; + if let Err(error) = app.emit(&request.event_name, &warning_event) { + tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error); + } + } { let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?; @@ -1962,10 +2044,37 @@ pub async fn aster_session_create( return Err("workspace_id 必填,请先选择项目工作区".to_string()); } + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager + .get(&workspace_id) + .map_err(|e| format!("读取 workspace 失败: {e}"))? + .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; + let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; + let workspace_root = ensured.root_path.to_string_lossy().to_string(); + + if ensured.repaired { + tracing::warn!( + "[AsterAgent] 会话创建阶段检测到 workspace 目录异常并已修复: {}{}", + workspace_root, + if ensured.relocated { + "(已迁移)" + } else { + "" + } + ); + } + + let resolved_working_dir = working_dir + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| Some(workspace_root.clone())); + AsterAgentWrapper::create_session_sync( &db, name, - working_dir, + resolved_working_dir, workspace_id, Some( execution_strategy diff --git a/src-tauri/src/commands/config_cmd.rs b/src-tauri/src/commands/config_cmd.rs index 44eb775dd..b046df09a 100644 --- a/src-tauri/src/commands/config_cmd.rs +++ b/src-tauri/src/commands/config_cmd.rs @@ -5,6 +5,7 @@ use crate::config::{ use crate::models::app_type::AppType; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager}; use tauri_plugin_autostart::ManagerExt; @@ -596,6 +597,16 @@ pub struct VersionCheckResult { pub error: Option, } +const FALLBACK_TAGS_URL: &str = "https://github.com/aiclientproxy/proxycast/tags"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct UpdateCheckCache { + latest: Option, + download_url: Option, + etag: Option, + last_checked_unix: u64, +} + /// 检查应用更新 /// /// 从 GitHub Releases API 获取最新版本信息并与当前版本比较 @@ -604,17 +615,60 @@ pub async fn check_for_updates() -> Result { const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const GITHUB_API_URL: &str = "https://api.github.com/repos/aiclientproxy/proxycast/releases/latest"; + const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60; + + let now_unix = current_unix_timestamp(); + let cache_path = get_update_check_cache_path(); + let cached = load_update_check_cache(&cache_path); + + if let Some(cache) = &cached { + if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) { + return Ok(build_version_check_result( + CURRENT_VERSION, + cache.latest.clone(), + cache.download_url.clone(), + None, + )); + } + } let client = reqwest::Client::new(); - - match client + let mut request = client .get(GITHUB_API_URL) .header("User-Agent", "ProxyCast") - .send() - .await - { + .header("Accept", "application/vnd.github+json"); + + if let Some(cache) = &cached { + if let Some(etag) = &cache.etag { + request = request.header("If-None-Match", etag); + } + } + + match request.send().await { Ok(response) => { + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + if let Some(cache) = cached { + let refreshed_cache = UpdateCheckCache { + last_checked_unix: now_unix, + ..cache.clone() + }; + let _ = save_update_check_cache(&cache_path, &refreshed_cache); + return Ok(build_version_check_result( + CURRENT_VERSION, + refreshed_cache.latest, + refreshed_cache.download_url, + None, + )); + } + } + if response.status().is_success() { + let etag = response + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + match response.json::().await { Ok(data) => { let latest_version = data["tag_name"] @@ -624,44 +678,125 @@ pub async fn check_for_updates() -> Result { let download_url = data["html_url"].as_str().map(|s| s.to_string()); - let has_update = version_compare(CURRENT_VERSION, latest_version); - - Ok(VersionCheckResult { - current: CURRENT_VERSION.to_string(), + let new_cache = UpdateCheckCache { latest: Some(latest_version.to_string()), - has_update, + download_url: download_url.clone(), + etag, + last_checked_unix: now_unix, + }; + let _ = save_update_check_cache(&cache_path, &new_cache); + + Ok(build_version_check_result( + CURRENT_VERSION, + Some(latest_version.to_string()), download_url, - error: None, - }) + None, + )) } - Err(e) => Ok(VersionCheckResult { - current: CURRENT_VERSION.to_string(), - latest: None, - has_update: false, - download_url: None, - error: Some(format!("解析响应失败: {e}")), - }), + Err(e) => Ok(build_version_from_cache_or_default( + CURRENT_VERSION, + cached.as_ref(), + Some(format!("解析更新信息失败,已回退本地缓存: {e}")), + )), } } else { - Ok(VersionCheckResult { - current: CURRENT_VERSION.to_string(), - latest: None, - has_update: false, - download_url: None, - error: Some(format!("GitHub API 请求失败: {}", response.status())), - }) + let error_message = match response.status() { + reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS => { + "GitHub API 限流,已回退本地缓存,请稍后重试".to_string() + } + status => format!("GitHub API 请求失败: {status},已回退本地缓存"), + }; + + Ok(build_version_from_cache_or_default( + CURRENT_VERSION, + cached.as_ref(), + Some(error_message), + )) } } - Err(e) => Ok(VersionCheckResult { - current: CURRENT_VERSION.to_string(), - latest: None, - has_update: false, - download_url: None, - error: Some(format!("网络请求失败: {e}")), - }), + Err(e) => Ok(build_version_from_cache_or_default( + CURRENT_VERSION, + cached.as_ref(), + Some(format!("网络请求失败,已回退本地缓存: {e}")), + )), } } +fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn get_update_check_cache_path() -> PathBuf { + let base_dir = dirs::cache_dir() + .or_else(dirs::config_dir) + .unwrap_or_else(|| PathBuf::from(".")); + + base_dir.join("proxycast").join("update-check-cache.json") +} + +fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool { + if cache.latest.is_none() { + return false; + } + + now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs +} + +fn load_update_check_cache(path: &PathBuf) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str::(&content).ok() +} + +fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let content = serde_json::to_string(cache).map_err(|e| e.to_string())?; + std::fs::write(path, content).map_err(|e| e.to_string()) +} + +fn build_version_check_result( + current: &str, + latest: Option, + download_url: Option, + error: Option, +) -> VersionCheckResult { + let resolved_download_url = download_url.or_else(|| Some(FALLBACK_TAGS_URL.to_string())); + let has_update = latest + .as_deref() + .map(|latest_version| version_compare(current, latest_version)) + .unwrap_or(false); + + VersionCheckResult { + current: current.to_string(), + latest, + has_update, + download_url: resolved_download_url, + error, + } +} + +fn build_version_from_cache_or_default( + current: &str, + cache: Option<&UpdateCheckCache>, + error: Option, +) -> VersionCheckResult { + if let Some(cached) = cache { + return build_version_check_result( + current, + cached.latest.clone(), + cached.download_url.clone(), + error, + ); + } + + build_version_check_result(current, None, None, error) +} + /// 简单的版本比较函数 /// 返回 true 如果 latest > current fn version_compare(current: &str, latest: &str) -> bool { @@ -745,6 +880,25 @@ mod tests { assert!(patterns.is_empty()); } } + + #[test] + fn test_is_update_cache_fresh() { + let cache = UpdateCheckCache { + latest: Some("0.76.0".to_string()), + download_url: Some("https://example.com".to_string()), + etag: Some("etag".to_string()), + last_checked_unix: 100, + }; + + assert!(is_update_cache_fresh(&cache, 150, 60)); + assert!(!is_update_cache_fresh(&cache, 170, 60)); + + let cache_without_latest = UpdateCheckCache { + latest: None, + ..cache + }; + assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60)); + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 1d1feb8d9..e71767abc 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -39,6 +39,7 @@ fn arb_server_config() -> impl Strategy { port, api_key, tls: proxycast_core::config::TlsConfig::default(), + response_cache: proxycast_core::config::ResponseCacheSettings::default(), }) } @@ -371,6 +372,7 @@ fn arb_valid_server_config() -> impl Strategy { port, api_key, tls: proxycast_core::config::TlsConfig::default(), + response_cache: proxycast_core::config::ResponseCacheSettings::default(), }) } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 782375633..760fe28bf 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -93,6 +93,33 @@ pub async fn handle_command( Ok(serde_json::to_value(recent)?) } + "get_persisted_logs_tail" => { + let requested = args + .as_ref() + .and_then(|value| value.get("lines")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(200) + .clamp(20, 1000); + + let logs = state.logs.read().await; + let entries = logs.get_logs(); + let limit = entries.len().min(requested); + let recent: Vec<_> = entries + .into_iter() + .rev() + .take(limit) + .map(|e| { + serde_json::json!({ + "timestamp": e.timestamp, + "level": e.level, + "message": e.message, + }) + }) + .collect(); + Ok(serde_json::to_value(recent)?) + } + "clear_logs" => { state.logs.write().await.clear(); Ok(serde_json::json!({ "success": true })) diff --git a/src-tauri/src/services/novel_service.rs b/src-tauri/src/services/novel_service.rs index dde27d90d..1ac820b7a 100644 --- a/src-tauri/src/services/novel_service.rs +++ b/src-tauri/src/services/novel_service.rs @@ -1558,29 +1558,237 @@ fn row_to_run(row: &rusqlite::Row<'_>) -> Result Vec { - if let Ok(value) = serde_json::from_str::(raw) { - if let Some(arr) = value.as_array() { - return arr.clone(); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + if let Some(cards) = parse_character_cards_from_json_text(trimmed) { + return cards; + } + + let fenced = extract_first_markdown_code_block(trimmed); + if let Some(code) = fenced.as_ref() { + if let Some(cards) = parse_character_cards_from_json_text(code) { + return cards; } } - // 兜底:按行解析成简单角色卡 + if let Some(array_text) = extract_json_array_text(trimmed) { + if let Some(cards) = parse_character_cards_from_json_text(&array_text) { + return cards; + } + } + + if let Some(code) = fenced.as_ref() { + if let Some(array_text) = extract_json_array_text(code) { + if let Some(cards) = parse_character_cards_from_json_text(&array_text) { + return cards; + } + } + } + + // 兜底:按行解析成简单角色卡(过滤 JSON 噪音行) raw.lines() .filter_map(|line| { - let name = line.trim().trim_start_matches('-').trim(); - if name.is_empty() { - None - } else { - Some(json!({ - "name": name, - "role_type": "support", - "description": "" - })) - } + let name = sanitize_fallback_name(line)?; + Some(json!({ + "name": name, + "role_type": "support", + "description": "" + })) }) .collect() } +fn parse_character_cards_from_json_text(text: &str) -> Option> { + let value = serde_json::from_str::(text).ok()?; + extract_character_cards_from_value(&value) +} + +fn extract_character_cards_from_value(value: &Value) -> Option> { + if let Some(arr) = value.as_array() { + return Some( + arr.iter() + .enumerate() + .map(|(idx, item)| normalize_character_card(item, idx)) + .collect(), + ); + } + + let obj = value.as_object()?; + for key in [ + "characters", + "character_cards", + "cards", + "result", + "data", + "roles", + ] { + if let Some(arr) = obj.get(key).and_then(Value::as_array) { + return Some( + arr.iter() + .enumerate() + .map(|(idx, item)| normalize_character_card(item, idx)) + .collect(), + ); + } + } + + if obj.get("name").is_some() || obj.get("role_type").is_some() { + return Some(vec![normalize_character_card(value, 0)]); + } + + None +} + +fn normalize_character_card(value: &Value, index: usize) -> Value { + let mut obj = value.as_object().cloned().unwrap_or_default(); + let name = extract_character_name(&obj).unwrap_or_else(|| format!("角色{}", index + 1)); + let role_type = extract_role_type(&obj); + obj.insert("name".to_string(), Value::String(name)); + obj.insert("role_type".to_string(), Value::String(role_type)); + Value::Object(obj) +} + +fn extract_character_name(obj: &serde_json::Map) -> Option { + let candidate_keys = ["name", "character_name", "characterName", "角色名", "角色"]; + for key in candidate_keys { + if let Some(raw_name) = obj.get(key).and_then(Value::as_str) { + if let Some(name) = sanitize_candidate_name(raw_name) { + return Some(name); + } + } + } + None +} + +fn extract_role_type(obj: &serde_json::Map) -> String { + let candidate_keys = ["role_type", "roleType", "type", "role", "角色类型"]; + for key in candidate_keys { + if let Some(value) = obj.get(key).and_then(Value::as_str) { + return normalize_role_type(value); + } + } + "support".to_string() +} + +fn normalize_role_type(raw: &str) -> String { + let lowered = raw.trim().to_lowercase(); + if lowered.contains("main") + || lowered.contains("protagonist") + || lowered.contains("主角") + || lowered.contains("主人公") + { + return "main".to_string(); + } + if lowered.contains("antagonist") + || lowered.contains("villain") + || lowered.contains("反派") + || lowered.contains("敌人") + { + return "antagonist".to_string(); + } + "support".to_string() +} + +fn extract_first_markdown_code_block(raw: &str) -> Option { + let start = raw.find("```")?; + let remain = &raw[start + 3..]; + let content_start = remain.find('\n')?; + let content = &remain[content_start + 1..]; + let end = content.find("```")?; + Some(content[..end].trim().to_string()) +} + +fn extract_json_array_text(raw: &str) -> Option { + let mut depth = 0usize; + let mut start_index: Option = None; + let mut in_string = false; + let mut escaped = false; + + for (idx, ch) in raw.char_indices() { + if in_string { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => in_string = true, + '[' => { + if depth == 0 { + start_index = Some(idx); + } + depth += 1; + } + ']' => { + if depth == 0 { + continue; + } + depth -= 1; + if depth == 0 { + if let Some(start) = start_index { + return Some(raw[start..idx + 1].to_string()); + } + } + } + _ => {} + } + } + + None +} + +fn sanitize_fallback_name(line: &str) -> Option { + let mut normalized = line.trim(); + normalized = normalized + .trim_start_matches('-') + .trim_start_matches('*') + .trim(); + if normalized.is_empty() { + return None; + } + sanitize_candidate_name(normalized) +} + +fn sanitize_candidate_name(raw: &str) -> Option { + let normalized = raw.trim().trim_matches('"').trim_end_matches(',').trim(); + if normalized.is_empty() { + return None; + } + if looks_like_json_noise_line(normalized) { + return None; + } + Some(normalized.to_string()) +} + +fn looks_like_json_noise_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return true; + } + if matches!(trimmed, "{" | "}" | "[" | "]" | ",") { + return true; + } + if trimmed.starts_with("//") { + return true; + } + if trimmed.contains("\":") || trimmed.ends_with(':') { + return true; + } + false +} + fn split_title_and_content(raw: &str, chapter_no: i32) -> (String, String) { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -2405,3 +2613,51 @@ fn value_as_f64(value: Option<&Value>, fallback: f64) -> f64 { .or_else(|| value.as_u64().map(|v| v as f64)) .unwrap_or(fallback) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_character_cards_should_support_markdown_json_block() { + let raw = r#" +这里是角色卡: +```json +[ + { + "name": "顾清", + "role_type": "main", + "relationship": "主角" + }, + { + "name": "宿敌", + "role_type": "antagonist" + } +] +``` +"#; + + let cards = parse_character_cards(raw); + assert_eq!(cards.len(), 2); + assert_eq!(cards[0]["name"], Value::String("顾清".to_string())); + assert_eq!(cards[0]["role_type"], Value::String("main".to_string())); + assert_eq!( + cards[1]["role_type"], + Value::String("antagonist".to_string()) + ); + } + + #[test] + fn parse_character_cards_should_filter_json_noise_in_fallback_lines() { + let raw = r#" +{ + "relationship": "上司", + "arc": "成长", + "abilities": "强大的调查能力", +} +"#; + + let cards = parse_character_cards(raw); + assert!(cards.is_empty()); + } +}