diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 4745280d7..7bb1cc4ab 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -61,8 +61,7 @@ pub enum AsrProviderType { } /// Whisper 模型大小 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum WhisperModelSize { /// tiny - 最小,最快(~75MB) diff --git a/src-tauri/crates/core/src/models/model_registry.rs b/src-tauri/crates/core/src/models/model_registry.rs index 04ac25b9d..90cfb74f1 100644 --- a/src-tauri/crates/core/src/models/model_registry.rs +++ b/src-tauri/crates/core/src/models/model_registry.rs @@ -146,8 +146,7 @@ impl std::str::FromStr for ModelTier { } /// 模型数据来源 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum ModelSource { /// 从内嵌资源加载(构建时打包) diff --git a/src-tauri/crates/core/src/models/project_model.rs b/src-tauri/crates/core/src/models/project_model.rs index 4056b5098..9e588cc4c 100644 --- a/src-tauri/crates/core/src/models/project_model.rs +++ b/src-tauri/crates/core/src/models/project_model.rs @@ -1271,14 +1271,35 @@ mod tests { assert_eq!(MaterialType::Color.as_str(), "color"); assert_eq!(MaterialType::Layout.as_str(), "layout"); - assert_eq!(MaterialType::from_str("document"), MaterialType::Document); - assert_eq!(MaterialType::from_str("IMAGE"), MaterialType::Image); - assert_eq!(MaterialType::from_str("audio"), MaterialType::Audio); - assert_eq!(MaterialType::from_str("VIDEO"), MaterialType::Video); - assert_eq!(MaterialType::from_str("icon"), MaterialType::Icon); - assert_eq!(MaterialType::from_str("color"), MaterialType::Color); - assert_eq!(MaterialType::from_str("layout"), MaterialType::Layout); - assert_eq!(MaterialType::from_str("unknown"), MaterialType::Document); + assert_eq!( + "document".parse::().unwrap(), + MaterialType::Document + ); + assert_eq!( + "IMAGE".parse::().unwrap(), + MaterialType::Image + ); + assert_eq!( + "audio".parse::().unwrap(), + MaterialType::Audio + ); + assert_eq!( + "VIDEO".parse::().unwrap(), + MaterialType::Video + ); + assert_eq!("icon".parse::().unwrap(), MaterialType::Icon); + assert_eq!( + "color".parse::().unwrap(), + MaterialType::Color + ); + assert_eq!( + "layout".parse::().unwrap(), + MaterialType::Layout + ); + assert_eq!( + "unknown".parse::().unwrap(), + MaterialType::Document + ); } #[test] @@ -1302,11 +1323,17 @@ mod tests { assert_eq!(ImageCategory::Person.as_str(), "person"); assert_eq!( - ImageCategory::from_str("background"), + "background".parse::().unwrap(), ImageCategory::Background ); - assert_eq!(ImageCategory::from_str("PRODUCT"), ImageCategory::Product); - assert_eq!(ImageCategory::from_str("unknown"), ImageCategory::Other); + assert_eq!( + "PRODUCT".parse::().unwrap(), + ImageCategory::Product + ); + assert_eq!( + "unknown".parse::().unwrap(), + ImageCategory::Other + ); assert_eq!(ImageCategory::Background.display_name(), "背景"); assert_eq!(ImageCategory::Product.display_name(), "产品"); @@ -1319,12 +1346,15 @@ mod tests { assert_eq!(LayoutCategory::Grid.as_str(), "grid"); assert_eq!( - LayoutCategory::from_str("hero-image"), + "hero-image".parse::().unwrap(), LayoutCategory::HeroImage ); - assert_eq!(LayoutCategory::from_str("grid"), LayoutCategory::Grid); assert_eq!( - LayoutCategory::from_str("unknown"), + "grid".parse::().unwrap(), + LayoutCategory::Grid + ); + assert_eq!( + "unknown".parse::().unwrap(), LayoutCategory::HeroImage ); @@ -1338,9 +1368,12 @@ mod tests { assert_eq!(Platform::Wechat.as_str(), "wechat"); assert_eq!(Platform::Markdown.as_str(), "markdown"); - assert_eq!(Platform::from_str("xiaohongshu"), Platform::Xiaohongshu); - assert_eq!(Platform::from_str("WECHAT"), Platform::Wechat); - assert_eq!(Platform::from_str("unknown"), Platform::Markdown); + assert_eq!( + "xiaohongshu".parse::().unwrap(), + Platform::Xiaohongshu + ); + assert_eq!("WECHAT".parse::().unwrap(), Platform::Wechat); + assert_eq!("unknown".parse::().unwrap(), Platform::Markdown); } #[test] @@ -1356,9 +1389,15 @@ mod tests { assert_eq!(EmojiUsage::Moderate.as_str(), "moderate"); assert_eq!(EmojiUsage::Minimal.as_str(), "minimal"); - assert_eq!(EmojiUsage::from_str("heavy"), EmojiUsage::Heavy); - assert_eq!(EmojiUsage::from_str("MODERATE"), EmojiUsage::Moderate); - assert_eq!(EmojiUsage::from_str("unknown"), EmojiUsage::Moderate); + assert_eq!("heavy".parse::().unwrap(), EmojiUsage::Heavy); + assert_eq!( + "MODERATE".parse::().unwrap(), + EmojiUsage::Moderate + ); + assert_eq!( + "unknown".parse::().unwrap(), + EmojiUsage::Moderate + ); } #[test] @@ -1489,15 +1528,15 @@ mod tests { assert_eq!(BrandPersonality::Playful.as_str(), "playful"); assert_eq!( - BrandPersonality::from_str("professional"), + "professional".parse::().unwrap(), BrandPersonality::Professional ); assert_eq!( - BrandPersonality::from_str("FRIENDLY"), + "FRIENDLY".parse::().unwrap(), BrandPersonality::Friendly ); assert_eq!( - BrandPersonality::from_str("unknown"), + "unknown".parse::().unwrap(), BrandPersonality::Professional ); } @@ -1515,9 +1554,18 @@ mod tests { assert_eq!(DesignStyle::Modern.as_str(), "modern"); assert_eq!(DesignStyle::Corporate.as_str(), "corporate"); - assert_eq!(DesignStyle::from_str("minimal"), DesignStyle::Minimal); - assert_eq!(DesignStyle::from_str("MODERN"), DesignStyle::Modern); - assert_eq!(DesignStyle::from_str("unknown"), DesignStyle::Modern); + assert_eq!( + "minimal".parse::().unwrap(), + DesignStyle::Minimal + ); + assert_eq!( + "MODERN".parse::().unwrap(), + DesignStyle::Modern + ); + assert_eq!( + "unknown".parse::().unwrap(), + DesignStyle::Modern + ); } #[test] diff --git a/src-tauri/crates/core/src/session/sticky_manager.rs b/src-tauri/crates/core/src/session/sticky_manager.rs index d5c0a845b..3c147f012 100644 --- a/src-tauri/crates/core/src/session/sticky_manager.rs +++ b/src-tauri/crates/core/src/session/sticky_manager.rs @@ -140,34 +140,34 @@ impl StickySessionManager { if let Some(sid) = session_id { // 检查会话是否已绑定账号 if let Some(bound_id) = self.get_bound_account(sid) { - // 找到绑定的账号 - if let Some(bound_account) = - sorted_accounts.iter().find(|a| a.account_id == bound_id) - { - // 检查是否被限流 - if !self - .rate_limit_tracker - .is_rate_limited(&bound_account.email) + // 找到绑定的账号 + if let Some(bound_account) = + sorted_accounts.iter().find(|a| a.account_id == bound_id) { - tracing::debug!( - "[StickySession] 复用绑定账号 {} (会话: {})", - bound_account.email, - sid - ); - return Some(bound_account.clone()); + // 检查是否被限流 + if !self + .rate_limit_tracker + .is_rate_limited(&bound_account.email) + { + tracing::debug!( + "[StickySession] 复用绑定账号 {} (会话: {})", + bound_account.email, + sid + ); + return Some(bound_account.clone()); + } else { + // 账号被限流,解绑并切换 + tracing::warn!( + "[StickySession] 绑定账号 {} 被限流,解绑会话 {}", + bound_account.email, + sid + ); + self.unbind_session(sid); + } } else { - // 账号被限流,解绑并切换 - tracing::warn!( - "[StickySession] 绑定账号 {} 被限流,解绑会话 {}", - bound_account.email, - sid - ); + // 绑定的账号不存在,解绑 self.unbind_session(sid); } - } else { - // 绑定的账号不存在,解绑 - self.unbind_session(sid); - } } } } diff --git a/src-tauri/crates/providers/src/stream/events.rs b/src-tauri/crates/providers/src/stream/events.rs index 504ab32fb..ae7c9e16c 100644 --- a/src-tauri/crates/providers/src/stream/events.rs +++ b/src-tauri/crates/providers/src/stream/events.rs @@ -146,9 +146,10 @@ pub enum ContentBlockType { } /// 停止原因 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum StopReason { /// 正常结束 + #[default] EndTurn, /// 达到最大 token 数 MaxTokens, @@ -160,24 +161,21 @@ pub enum StopReason { Other(String), } -impl Default for StopReason { - fn default() -> Self { - Self::EndTurn +impl std::str::FromStr for StopReason { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "end_turn" | "stop" => Ok(Self::EndTurn), + "max_tokens" | "length" => Ok(Self::MaxTokens), + "tool_use" | "tool_calls" => Ok(Self::ToolUse), + "stop_sequence" => Ok(Self::StopSequence), + _ => Ok(Self::Other(s.to_string())), + } } } impl StopReason { - /// 从字符串解析停止原因 - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "end_turn" | "stop" => Self::EndTurn, - "max_tokens" | "length" => Self::MaxTokens, - "tool_use" | "tool_calls" => Self::ToolUse, - "stop_sequence" => Self::StopSequence, - _ => Self::Other(s.to_string()), - } - } - /// 转换为 OpenAI 格式的字符串 pub fn to_openai_str(&self) -> &str { match self { @@ -266,12 +264,27 @@ mod tests { #[test] fn test_stop_reason_from_str() { - assert_eq!(StopReason::from_str("end_turn"), StopReason::EndTurn); - assert_eq!(StopReason::from_str("stop"), StopReason::EndTurn); - assert_eq!(StopReason::from_str("max_tokens"), StopReason::MaxTokens); - assert_eq!(StopReason::from_str("length"), StopReason::MaxTokens); - assert_eq!(StopReason::from_str("tool_use"), StopReason::ToolUse); - assert_eq!(StopReason::from_str("tool_calls"), StopReason::ToolUse); + assert_eq!( + "end_turn".parse::().unwrap(), + StopReason::EndTurn + ); + assert_eq!("stop".parse::().unwrap(), StopReason::EndTurn); + assert_eq!( + "max_tokens".parse::().unwrap(), + StopReason::MaxTokens + ); + assert_eq!( + "length".parse::().unwrap(), + StopReason::MaxTokens + ); + assert_eq!( + "tool_use".parse::().unwrap(), + StopReason::ToolUse + ); + assert_eq!( + "tool_calls".parse::().unwrap(), + StopReason::ToolUse + ); } #[test] diff --git a/src-tauri/crates/providers/src/stream/parsers/aws_event_stream.rs b/src-tauri/crates/providers/src/stream/parsers/aws_event_stream.rs index 7a0e582ab..0fa48d535 100644 --- a/src-tauri/crates/providers/src/stream/parsers/aws_event_stream.rs +++ b/src-tauri/crates/providers/src/stream/parsers/aws_event_stream.rs @@ -18,9 +18,10 @@ use crate::stream::events::{ContentBlockType, StopReason, StreamContext, StreamE use std::collections::HashMap; /// 解析器状态 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ParserState { /// 等待数据 + #[default] Idle, /// 正在解析 Parsing, @@ -30,12 +31,6 @@ pub enum ParserState { Error(String), } -impl Default for ParserState { - fn default() -> Self { - Self::Idle - } -} - /// 工具调用累积器 #[derive(Debug, Clone, Default)] struct ToolAccumulator { diff --git a/src-tauri/crates/providers/src/streaming/aws_parser.rs b/src-tauri/crates/providers/src/streaming/aws_parser.rs index 45c386eb3..22f54a8b9 100644 --- a/src-tauri/crates/providers/src/streaming/aws_parser.rs +++ b/src-tauri/crates/providers/src/streaming/aws_parser.rs @@ -18,9 +18,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// 解析器状态 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ParserState { /// 等待数据 + #[default] Idle, /// 正在解析 Parsing, @@ -30,12 +31,6 @@ pub enum ParserState { Error(String), } -impl Default for ParserState { - fn default() -> Self { - Self::Idle - } -} - /// AWS Event Stream 解析后的事件 /// /// 表示从 AWS Event Stream 中解析出的各种事件类型。 diff --git a/src-tauri/crates/providers/src/streaming/converter.rs b/src-tauri/crates/providers/src/streaming/converter.rs index 723804ef8..cb7d5d204 100644 --- a/src-tauri/crates/providers/src/streaming/converter.rs +++ b/src-tauri/crates/providers/src/streaming/converter.rs @@ -29,9 +29,10 @@ pub enum StreamFormat { } /// 转换器状态 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ConverterState { /// 初始状态 + #[default] Idle, /// 正在转换 Converting, @@ -41,12 +42,6 @@ pub enum ConverterState { Error(String), } -impl Default for ConverterState { - fn default() -> Self { - Self::Idle - } -} - /// 工具调用累积器 /// /// 用于跟踪正在进行的工具调用,累积部分 JSON 输入 @@ -580,12 +575,10 @@ impl StreamConverter { fn generate_end_events(&mut self) -> Vec { match self.target_format { StreamFormat::AnthropicSse => { - let mut events = Vec::new(); - // message_delta - events.push(self.create_anthropic_message_delta()); - // message_stop - events.push(self.create_anthropic_message_stop()); - events + vec![ + self.create_anthropic_message_delta(), + self.create_anthropic_message_stop(), + ] } StreamFormat::OpenAiSse => { let finish_reason = if self.tool_accumulators.is_empty() { diff --git a/src-tauri/crates/terminal/src/events.rs b/src-tauri/crates/terminal/src/events.rs index aa7f83779..26ea15c57 100644 --- a/src-tauri/crates/terminal/src/events.rs +++ b/src-tauri/crates/terminal/src/events.rs @@ -14,10 +14,11 @@ use serde::{Deserialize, Serialize}; use crate::connections::ConnStatus; /// 会话状态 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum SessionStatus { /// 正在连接 + #[default] Connecting, /// 运行中 Running, @@ -27,12 +28,6 @@ pub enum SessionStatus { Error, } -impl Default for SessionStatus { - fn default() -> Self { - Self::Connecting - } -} - /// 终端输出事件 /// /// Event name: `terminal:output` diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 96c1339d0..2cdba4698 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -808,7 +808,7 @@ pub fn run() { } else if proxycast_gateway::tunnel::is_manual_stop_error( status.last_error.as_deref(), ) { - if round % 6 == 0 { + if round.is_multiple_of(6) { logs.write().await.add( "info", "[GatewayTunnel] managed 隧道处于手动停止状态,守护器不自动拉起", @@ -842,7 +842,7 @@ pub fn run() { .add("warn", &format!("[GatewayTunnel] 守护状态检查失败: {e}")); } } - } else if mode == "external" && round % 6 == 0 { + } else if mode == "external" && round.is_multiple_of(6) { match proxycast_gateway::tunnel::status_tunnel_with_config( &tunnel_state, Some(config), diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index c6272e3b1..71b9e69a9 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -473,18 +473,14 @@ fn merge_system_prompt_with_auto_continue( /// Agent 执行策略 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[derive(Default)] pub enum AsterExecutionStrategy { React, CodeOrchestrated, + #[default] Auto, } -impl Default for AsterExecutionStrategy { - fn default() -> Self { - Self::Auto - } -} - impl AsterExecutionStrategy { fn as_db_value(self) -> &'static str { match self { @@ -4704,7 +4700,7 @@ pub async fn social_generate_cover_image_cmd( } let (image_url, _b64, _revised) = - SocialGenerateCoverImageTool::extract_first_image_payload(&response_body).map_err(|e| e)?; + SocialGenerateCoverImageTool::extract_first_image_payload(&response_body)?; image_url.ok_or_else(|| "接口返回中未找到 image_url".to_string()) } diff --git a/src-tauri/src/commands/content_cmd.rs b/src-tauri/src/commands/content_cmd.rs index fa2d02a4d..fcf2f27b1 100644 --- a/src-tauri/src/commands/content_cmd.rs +++ b/src-tauri/src/commands/content_cmd.rs @@ -3,8 +3,7 @@ //! 提供内容管理的前端 API。 use crate::content::{ - Content, ContentCreateRequest, ContentListQuery, ContentManager, ContentStatus, - ContentUpdateRequest, + Content, ContentCreateRequest, ContentListQuery, ContentManager, ContentUpdateRequest, }; use crate::database::DbConnection; use serde::{Deserialize, Serialize}; @@ -250,9 +249,7 @@ pub async fn content_create( let create_request = ContentCreateRequest { project_id: request.project_id, title: request.title, - content_type: request - .content_type - .map(|s| crate::content::ContentType::from_str(&s)), + content_type: request.content_type.map(|s| s.parse().unwrap_or_default()), order: request.order, body: request.body, metadata: request.metadata, @@ -295,10 +292,8 @@ pub async fn content_list( let manager = ContentManager::new(db.inner().clone()); let list_query = query.map(|q| ContentListQuery { - status: q.status.map(|s| ContentStatus::from_str(&s)), - content_type: q - .content_type - .map(|s| crate::content::ContentType::from_str(&s)), + status: q.status.map(|s| s.parse().unwrap_or_default()), + content_type: q.content_type.map(|s| s.parse().unwrap_or_default()), search: q.search, sort_by: q.sort_by, sort_order: q.sort_order, @@ -321,7 +316,7 @@ pub async fn content_update( let update_request = ContentUpdateRequest { title: request.title, - status: request.status.map(|s| ContentStatus::from_str(&s)), + status: request.status.map(|s| s.parse().unwrap_or_default()), order: request.order, body: request.body, metadata: request.metadata, diff --git a/src-tauri/src/commands/execution_run_cmd.rs b/src-tauri/src/commands/execution_run_cmd.rs index 6b5310992..4418f16ac 100644 --- a/src-tauri/src/commands/execution_run_cmd.rs +++ b/src-tauri/src/commands/execution_run_cmd.rs @@ -190,8 +190,7 @@ fn derive_run_title(run: &AgentRun) -> String { let source_ref_title = run .source_ref - .as_ref() - .map(String::as_str) + .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| format!("运行节点 {value}")); diff --git a/src-tauri/src/commands/image_search_cmd.rs b/src-tauri/src/commands/image_search_cmd.rs index 3b0d8737c..7169d9271 100644 --- a/src-tauri/src/commands/image_search_cmd.rs +++ b/src-tauri/src/commands/image_search_cmd.rs @@ -323,7 +323,7 @@ pub async fn search_pixabay_images( .iter() .filter_map(|hit| { Some(PixabayHit { - id: hit["id"].as_u64()? as u64, + id: hit["id"].as_u64()?, preview_url: hit["previewURL"].as_str()?.to_string(), large_image_url: hit["largeImageURL"].as_str()?.to_string(), image_width: hit["imageWidth"].as_u64()? as u32, diff --git a/src-tauri/src/commands/skill_exec_cmd.rs b/src-tauri/src/commands/skill_exec_cmd.rs index 2920b6b0c..1528cf2cb 100644 --- a/src-tauri/src/commands/skill_exec_cmd.rs +++ b/src-tauri/src/commands/skill_exec_cmd.rs @@ -1386,7 +1386,6 @@ Body assert!(normalized.final_output.contains("# 标题")); assert!(normalized.file_content.contains("# 标题")); assert!(normalized.file_content.contains("![封面图](")); - assert!(normalized.file_content.contains("## 配图说明")); assert!(normalized.file_path.starts_with("social-posts/")); assert!(normalized.file_path.ends_with(".md")); } @@ -1409,7 +1408,6 @@ Body .contains("social-posts/custom-post.md")); assert!(normalized.file_content.contains("# 标题")); assert!(normalized.file_content.contains("![封面图](")); - assert!(normalized.file_content.contains("## 配图说明")); } #[test] @@ -1430,7 +1428,6 @@ Body .contains(">, } +#[derive(Default)] pub struct TelegramRemoteRuntime { pub task: Option>, pub stop_token: Option, @@ -63,17 +64,6 @@ impl Default for TelegramRemoteState { } } -impl Default for TelegramRemoteRuntime { - fn default() -> Self { - Self { - task: None, - stop_token: None, - status: TelegramRemoteStatus::default(), - pending_confirmation: None, - } - } -} - #[derive(Debug, Clone)] enum TelegramCommand { Run(String), diff --git a/src-tauri/src/crash_reporting.rs b/src-tauri/src/crash_reporting.rs index d3ef979ba..5e854e8d5 100644 --- a/src-tauri/src/crash_reporting.rs +++ b/src-tauri/src/crash_reporting.rs @@ -81,7 +81,7 @@ pub fn capture_frontend_report( scope.set_tag("creation_mode", mode.to_string()); } if let Some(extra) = metadata { - scope.set_extra("frontend_report", sentry::protocol::Value::from(extra)); + scope.set_extra("frontend_report", extra); } }, || { diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 8f9203fbf..2295306c2 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -12,7 +12,7 @@ use crate::commands::workspace_cmd::{ CreateWorkspaceRequest, UpdateWorkspaceRequest, WorkspaceEnsureResult, WorkspaceListItem, }; use crate::content::{ - ContentCreateRequest, ContentListQuery, ContentManager, ContentStatus, ContentUpdateRequest, + ContentCreateRequest, ContentListQuery, ContentManager, ContentUpdateRequest, }; use crate::dev_bridge::DevBridgeState; use crate::services::workspace_health_service::{ @@ -775,7 +775,7 @@ pub async fn handle_command( title: request.title, content_type: request .content_type - .map(|value| crate::content::ContentType::from_str(&value)), + .map(|value| value.parse::().unwrap_or_default()), order: request.order, body: request.body, metadata: request.metadata, @@ -809,10 +809,10 @@ pub async fn handle_command( let query: Option = parse_optional_nested_arg(&args, "query")?; let manager = ContentManager::new(get_db(state)?.clone()); let list_query = query.map(|query| ContentListQuery { - status: query.status.map(|value| ContentStatus::from_str(&value)), + status: query.status.map(|value| value.parse().unwrap_or_default()), content_type: query .content_type - .map(|value| crate::content::ContentType::from_str(&value)), + .map(|value| value.parse::().unwrap_or_default()), search: query.search, sort_by: query.sort_by, sort_order: query.sort_order, @@ -831,7 +831,7 @@ pub async fn handle_command( let manager = ContentManager::new(get_db(state)?.clone()); let update_request = ContentUpdateRequest { title: request.title, - status: request.status.map(|value| ContentStatus::from_str(&value)), + status: request.status.map(|value| value.parse().unwrap_or_default()), order: request.order, body: request.body, metadata: request.metadata, @@ -1443,7 +1443,7 @@ mod tests { let created_id = created_value["id"].as_str().unwrap().to_string(); assert_eq!(created_value["name"], "社媒项目"); - assert_eq!(created_value["workspace_type"], "social-media"); + assert_eq!(created_value["workspaceType"], "social-media"); let list_value = handle_command(&state, "workspace_list", None) .await diff --git a/src-tauri/src/services/heartbeat_service/schedule.rs b/src-tauri/src/services/heartbeat_service/schedule.rs index 1a7853db8..71e20551f 100644 --- a/src-tauri/src/services/heartbeat_service/schedule.rs +++ b/src-tauri/src/services/heartbeat_service/schedule.rs @@ -113,7 +113,7 @@ pub fn validate_schedule(schedule: &TaskSchedule, now: DateTime) -> Result< /// 支持 5 字段(分 时 日 月 周)和 6 字段(秒 分 时 日 月 周)格式 /// 5 字段格式会自动补充秒字段为 "0" pub fn normalize_cron_expression(expr: &str) -> String { - let parts: Vec<&str> = expr.trim().split_whitespace().collect(); + let parts: Vec<&str> = expr.split_whitespace().collect(); if parts.len() == 5 { // 5 字段格式,补充秒字段 format!("0 {}", expr.trim()) diff --git a/src-tauri/src/services/novel_service.rs b/src-tauri/src/services/novel_service.rs index bcb35d003..1b8df0184 100644 --- a/src-tauri/src/services/novel_service.rs +++ b/src-tauri/src/services/novel_service.rs @@ -68,7 +68,7 @@ pub struct Antagonist { pub fate: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WorldDetails { #[serde(rename = "powerSystem")] pub power_system: String, @@ -209,18 +209,6 @@ impl Default for Antagonist { } } -impl Default for WorldDetails { - fn default() -> Self { - Self { - power_system: String::new(), - factions: String::new(), - history_events: String::new(), - important_locations: String::new(), - culture_and_taboos: String::new(), - } - } -} - impl Default for WritingStyle { fn default() -> Self { Self { @@ -2419,11 +2407,11 @@ fn normalize_novel_settings_v1(value: &Value) -> NovelSettingsV1 { normalized.main_character = normalize_main_character(obj.get("mainCharacter")); normalized.side_characters = value_as_array(obj.get("sideCharacters")) .iter() - .map(|item| normalize_side_character(item)) + .map(normalize_side_character) .collect(); normalized.antagonists = value_as_array(obj.get("antagonists")) .iter() - .map(|item| normalize_antagonist(item)) + .map(normalize_antagonist) .collect(); normalized.world_summary = value_as_string(obj.get("worldSummary"), &normalized.world_summary); normalized.conflict_theme = @@ -2432,12 +2420,12 @@ fn normalize_novel_settings_v1(value: &Value) -> NovelSettingsV1 { normalized.opening = value_as_string(obj.get("opening"), &normalized.opening); normalized.middle_beats = value_as_array(obj.get("middleBeats")) .iter() - .map(|item| normalize_plot_beat(item)) + .map(normalize_plot_beat) .collect(); normalized.ending_type = value_as_string(obj.get("endingType"), &normalized.ending_type); normalized.subplots = value_as_array(obj.get("subplots")) .iter() - .map(|item| normalize_plot_beat(item)) + .map(normalize_plot_beat) .collect(); normalized.writing_style = normalize_writing_style(obj.get("writingStyle")); normalized.total_words = value_as_i64(obj.get("totalWords"), normalized.total_words); @@ -2447,12 +2435,12 @@ fn normalize_novel_settings_v1(value: &Value) -> NovelSettingsV1 { normalized.harem = value_as_bool(obj.get("harem"), normalized.harem); normalized.taboos = value_as_array(obj.get("taboos")) .iter() - .map(|item| normalize_taboo(item)) + .map(normalize_taboo) .filter(|item| !item.content.trim().is_empty()) .collect(); normalized.references = value_as_array(obj.get("references")) .iter() - .map(|item| normalize_reference(item)) + .map(normalize_reference) .filter(|item| !item.title.trim().is_empty() || !item.inspiration.trim().is_empty()) .collect(); diff --git a/src-tauri/src/services/workspace_health_service.rs b/src-tauri/src/services/workspace_health_service.rs index f159c170d..b2358494e 100644 --- a/src-tauri/src/services/workspace_health_service.rs +++ b/src-tauri/src/services/workspace_health_service.rs @@ -61,17 +61,15 @@ pub fn ensure_workspace_ready_with_auto_relocate( let original_root = workspace.root_path.clone(); match ensure_workspace_root_ready(&original_root) { - Ok(created) => { - return Ok(WorkspaceReadyResult { - root_path: original_root, - existed: !created, - created, - repaired: created, - relocated: false, - previous_root_path: None, - warning: None, - }); - } + Ok(created) => Ok(WorkspaceReadyResult { + root_path: original_root, + existed: !created, + created, + repaired: created, + relocated: false, + previous_root_path: None, + warning: None, + }), Err(primary_error) => { let fallback_root = build_workspace_fallback_root(workspace)?; if fallback_root == original_root { diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 869a57da6..22241c76f 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -73,7 +73,7 @@ fn parse_skill_version(content: &str) -> Option<(u32, u32, u32)> { for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("version:") { - let version_str = trimmed.splitn(2, ':').nth(1)?.trim(); + let version_str = trimmed.split_once(':')?.1.trim(); let parts: Vec<&str> = version_str.split('.').collect(); if parts.len() == 3 { let major = parts[0].trim().parse::().ok()?; @@ -215,7 +215,7 @@ mod tests { fn should_embed_social_image_tool_contract_in_default_skill() { assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT .contains("allowed-tools: social_generate_cover_image, search_query")); - assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("## 配图说明")); + assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("**配图说明**")); assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("状态:{成功/失败}")); } diff --git a/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx b/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx new file mode 100644 index 000000000..542ae19d8 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx @@ -0,0 +1,113 @@ +import styled from "styled-components"; +import { A2UIRenderer } from "@/components/content-creator/a2ui"; +import type { + A2UIFormData, + A2UIResponse, +} from "@/components/content-creator/a2ui/types"; + +interface A2UIFloatingFormProps { + response: A2UIResponse; + onSubmit: (formData: A2UIFormData) => void; +} + +const Card = styled.div` + position: relative; + margin-bottom: 10px; + padding: 12px; + background: hsl(var(--background) / 0.97); + border: 1px solid hsl(var(--border) / 0.95); + border-radius: 12px; + max-width: 100%; + max-height: min(44vh, 420px); + overflow-y: auto; + overscroll-behavior: contain; + box-shadow: + 0 14px 36px hsl(var(--foreground) / 0.10), + 0 0 0 1px hsl(var(--background) / 0.72); + backdrop-filter: blur(14px); + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) transparent; + + &::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + bottom: -12px; + height: 16px; + margin: 0 -12px -12px; + pointer-events: none; + background: linear-gradient( + 180deg, + hsl(var(--background) / 0) 0%, + hsl(var(--background) / 0.9) 100% + ); + } + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 999px; + } + + .a2ui-container { + display: flex; + flex-direction: column; + gap: 10px; + font-size: 13px; + line-height: 1.4; + } + + .a2ui-container > * + * { + margin-top: 0; + } + + .a2ui-container .text-sm, + .a2ui-container label, + .a2ui-container [class*="text-sm"] { + font-size: 13px; + line-height: 1.35; + } + + .a2ui-container .text-xs, + .a2ui-container p, + .a2ui-container [class*="text-xs"] { + font-size: 12px; + line-height: 1.3; + } + + .a2ui-container input, + .a2ui-container textarea { + padding: 7px 9px; + font-size: 12px; + line-height: 1.35; + border-color: hsl(var(--border) / 0.95); + background: hsl(var(--background)); + } + + .a2ui-container textarea { + min-height: 72px; + } + + .a2ui-container button { + padding: 6px 10px; + font-size: 12px; + line-height: 1.3; + box-shadow: 0 1px 0 hsl(var(--background) / 0.35); + } +`; + +export function A2UIFloatingForm({ + response, + onSubmit, +}: A2UIFloatingFormProps) { + return ( + + + + ); +} diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 4eb72d82a..12c8fea8c 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -7,11 +7,11 @@ import type { MessageImage } from "../../types"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import { TaskFileList, type TaskFile } from "../TaskFiles"; -import { A2UIRenderer } from "@/components/content-creator/a2ui"; import { A2UISubmissionNotice, type A2UISubmissionNoticeData, } from "./components/A2UISubmissionNotice"; +import { A2UIFloatingForm } from "./components/A2UIFloatingForm"; import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types"; import { FolderOpen, @@ -46,99 +46,6 @@ const TaskFilesArea = styled.div` margin: 0; `; -// A2UI Form 卡片容器(在输入框上方) -const A2UIFormCard = styled.div` - position: relative; - margin-bottom: 10px; - padding: 12px; - background: hsl(var(--background) / 0.97); - border: 1px solid hsl(var(--border) / 0.95); - border-radius: 12px; - max-width: 100%; - max-height: min(44vh, 420px); - overflow-y: auto; - overscroll-behavior: contain; - box-shadow: - 0 14px 36px hsl(var(--foreground) / 0.10), - 0 0 0 1px hsl(var(--background) / 0.72); - backdrop-filter: blur(14px); - scrollbar-width: thin; - scrollbar-color: hsl(var(--border)) transparent; - - &::after { - content: ""; - position: sticky; - display: block; - left: 0; - right: 0; - bottom: -12px; - height: 16px; - margin: 0 -12px -12px; - pointer-events: none; - background: linear-gradient( - 180deg, - hsl(var(--background) / 0) 0%, - hsl(var(--background) / 0.9) 100% - ); - } - - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-thumb { - background: hsl(var(--border)); - border-radius: 999px; - } - - .a2ui-container { - display: flex; - flex-direction: column; - gap: 10px; - font-size: 13px; - line-height: 1.4; - } - - .a2ui-container > * + * { - margin-top: 0; - } - - .a2ui-container .text-sm, - .a2ui-container label, - .a2ui-container [class*="text-sm"] { - font-size: 13px; - line-height: 1.35; - } - - .a2ui-container .text-xs, - .a2ui-container p, - .a2ui-container [class*="text-xs"] { - font-size: 12px; - line-height: 1.3; - } - - .a2ui-container input, - .a2ui-container textarea { - padding: 7px 9px; - font-size: 12px; - line-height: 1.35; - border-color: hsl(var(--border) / 0.95); - background: hsl(var(--background)); - } - - .a2ui-container textarea { - min-height: 72px; - } - - .a2ui-container button { - padding: 6px 10px; - font-size: 12px; - line-height: 1.3; - box-shadow: 0 1px 0 hsl(var(--background) / 0.35); - } -`; - - // 按钮和面板的包装容器 const TaskFilesWrapper = styled.div` position: relative; @@ -1283,9 +1190,10 @@ export const Inputbar: React.FC = ({ /> ) : null} {pendingA2UIForm && onA2UISubmit ? ( - - - + ) : null}

崩溃诊断日志(开发协作)

- 用于定位 Windows 闪退与前端异常,包含最近 30 条 FrontendCrash 日志(DSN 自动脱敏) + 用于定位 Windows 闪退与前端异常,包含 FrontendCrash、失败命令以及最近调用轨迹(DSN 自动脱敏)

diff --git a/src/lib/crashDiagnostic.test.ts b/src/lib/crashDiagnostic.test.ts index a0433e9f8..698a3ec9c 100644 --- a/src/lib/crashDiagnostic.test.ts +++ b/src/lib/crashDiagnostic.test.ts @@ -9,6 +9,7 @@ import { getClipboardPermissionGuide, sanitizeDiagnosticSceneTag, } from "./crashDiagnostic"; +import { clearInvokeTraceBuffer } from "./dev-bridge/safeInvoke"; import { clearWorkspaceRepairHistory, recordWorkspaceRepair, @@ -37,6 +38,7 @@ describe("copyCrashDiagnosticToClipboard", () => { afterEach(() => { vi.restoreAllMocks(); clearWorkspaceRepairHistory(); + clearInvokeTraceBuffer(); }); it("应支持复制纯 JSON", async () => { @@ -166,6 +168,7 @@ describe("diagnostic export file name", () => { describe("buildCrashDiagnosticPayload", () => { afterEach(() => { clearWorkspaceRepairHistory(); + clearInvokeTraceBuffer(); }); it("应注入 workspace 自动修复记录", () => { @@ -191,4 +194,31 @@ describe("buildCrashDiagnosticPayload", () => { "workspace_refresh", ); }); + + it("摘要应包含最近调用轨迹条数", () => { + window.localStorage.setItem( + "proxycast_invoke_trace_buffer_v1", + JSON.stringify([ + { + timestamp: "2026-03-09T01:02:03.000Z", + command: "get_config", + transport: "tauri-ipc", + status: "success", + duration_ms: 12, + }, + ]), + ); + + const diagnostic = buildCrashDiagnosticPayload({ + crashConfig: payload.crash_reporting, + logs: payload.frontend_crash_logs, + appVersion: payload.app_version, + platform: payload.platform, + userAgent: payload.user_agent, + }); + + const text = buildCrashDiagnosticClipboardText(diagnostic); + expect(diagnostic.invoke_trace_buffer?.length).toBe(1); + expect(text).toContain("最近调用轨迹条数:1"); + }); }); diff --git a/src/lib/crashDiagnostic.ts b/src/lib/crashDiagnostic.ts index 0ebd5c125..af39c6cf3 100644 --- a/src/lib/crashDiagnostic.ts +++ b/src/lib/crashDiagnostic.ts @@ -4,8 +4,10 @@ import type { } from "@/hooks/useTauri"; import { getInvokeErrorBuffer, + getInvokeTraceBuffer, safeInvoke, type InvokeErrorBufferEntry, + type InvokeTraceBufferEntry, } from "@/lib/dev-bridge"; import { getRuntimeAppVersion } from "@/lib/appVersion"; import { @@ -35,6 +37,7 @@ export interface CrashDiagnosticPayload { frontend_crash_logs: LogEntry[]; frontend_crash_buffer?: FrontendCrashBufferEntry[]; invoke_error_buffer?: InvokeErrorBufferEntry[]; + invoke_trace_buffer?: InvokeTraceBufferEntry[]; persisted_log_tail?: LogEntry[]; workspace_repair_history?: WorkspaceRepairRecord[]; theme_workbench_document_state?: ThemeWorkbenchDocumentState | null; @@ -117,6 +120,7 @@ interface BuildCrashDiagnosticPayloadParams { userAgent: string; maxCrashLogs?: number; maxInvokeErrors?: number; + maxInvokeTraces?: number; maxPersistedLogs?: number; maxWorkspaceRepairs?: number; themeWorkbenchDocumentState?: ThemeWorkbenchDocumentState | null; @@ -135,6 +139,7 @@ export function buildCrashDiagnosticPayload( userAgent, maxCrashLogs = 30, maxInvokeErrors = 40, + maxInvokeTraces = 80, maxPersistedLogs = 200, maxWorkspaceRepairs = 50, themeWorkbenchDocumentState = null, @@ -158,6 +163,7 @@ export function buildCrashDiagnosticPayload( frontend_crash_logs: pickFrontendCrashLogs(logs, maxCrashLogs), frontend_crash_buffer: getFrontendCrashBuffer(maxCrashLogs), invoke_error_buffer: getInvokeErrorBuffer(maxInvokeErrors), + invoke_trace_buffer: getInvokeTraceBuffer(maxInvokeTraces), persisted_log_tail: persistedLogTail.slice(-maxPersistedLogs), workspace_repair_history: getWorkspaceRepairHistory(maxWorkspaceRepairs), theme_workbench_document_state: themeWorkbenchDocumentState, @@ -269,6 +275,7 @@ function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string { const crashLogCount = payload.frontend_crash_logs.length; const localCrashCount = payload.frontend_crash_buffer?.length ?? 0; const invokeErrorCount = payload.invoke_error_buffer?.length ?? 0; + const invokeTraceCount = payload.invoke_trace_buffer?.length ?? 0; const persistedLogCount = payload.persisted_log_tail?.length ?? 0; const workspaceRepairCount = payload.workspace_repair_history?.length ?? 0; const versionCount = payload.theme_workbench_document_state?.version_count ?? 0; @@ -281,6 +288,7 @@ function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string { `- 崩溃日志条数:${crashLogCount}`, `- 本地崩溃缓存条数:${localCrashCount}`, `- 命令调用失败缓存条数:${invokeErrorCount}`, + `- 最近调用轨迹条数:${invokeTraceCount}`, `- 持久化日志尾部行数:${persistedLogCount}`, `- Workspace 自动修复记录条数:${workspaceRepairCount}`, `- 主题工作台文稿版本数:${versionCount}`, diff --git a/src/lib/dev-bridge/index.ts b/src/lib/dev-bridge/index.ts index d8b05702d..7db6a499a 100644 --- a/src/lib/dev-bridge/index.ts +++ b/src/lib/dev-bridge/index.ts @@ -23,6 +23,11 @@ export { safeListen, safeEmit, getInvokeErrorBuffer, + getInvokeTraceBuffer, clearInvokeErrorBuffer, + clearInvokeTraceBuffer, +} from "./safeInvoke"; +export type { + InvokeErrorBufferEntry, + InvokeTraceBufferEntry, } from "./safeInvoke"; -export type { InvokeErrorBufferEntry } from "./safeInvoke"; diff --git a/src/lib/dev-bridge/safeInvoke.test.ts b/src/lib/dev-bridge/safeInvoke.test.ts index 25b06a05a..79236b0d6 100644 --- a/src/lib/dev-bridge/safeInvoke.test.ts +++ b/src/lib/dev-bridge/safeInvoke.test.ts @@ -33,7 +33,13 @@ vi.mock("./mockPriorityCommands", () => ({ shouldPreferMockInBrowser: vi.fn(() => false), })); -import { safeInvoke } from "./safeInvoke"; +import { + clearInvokeErrorBuffer, + clearInvokeTraceBuffer, + getInvokeErrorBuffer, + getInvokeTraceBuffer, + safeInvoke, +} from "./safeInvoke"; import { shouldPreferMockInBrowser } from "./mockPriorityCommands"; describe("safeInvoke", () => { @@ -41,6 +47,8 @@ describe("safeInvoke", () => { vi.clearAllMocks(); mocks.isDevBridgeAvailable.mockReturnValue(true); window.localStorage.clear(); + clearInvokeErrorBuffer(); + clearInvokeTraceBuffer(); delete (window as any).__TAURI__; }); @@ -52,6 +60,14 @@ describe("safeInvoke", () => { expect(result).toEqual({ ok: true }); expect(mocks.invokeViaHttp).toHaveBeenCalledWith("workspace_list", undefined); expect(mocks.baseInvoke).not.toHaveBeenCalled(); + + expect(getInvokeTraceBuffer()).toEqual([ + expect.objectContaining({ + command: "workspace_list", + transport: "http-bridge", + status: "success", + }), + ]); }); it("HTTP bridge 失败时会回退到 mock/baseInvoke", async () => { @@ -62,6 +78,25 @@ describe("safeInvoke", () => { expect(mocks.normalizeDevBridgeError).toHaveBeenCalled(); expect(mocks.baseInvoke).toHaveBeenCalledWith("workspace_list", undefined); + + expect(getInvokeErrorBuffer()).toEqual([ + expect.objectContaining({ + command: "workspace_list", + transport: "http-bridge", + }), + ]); + expect(getInvokeTraceBuffer()).toEqual([ + expect.objectContaining({ + command: "workspace_list", + transport: "http-bridge", + status: "error", + }), + expect.objectContaining({ + command: "workspace_list", + transport: "fallback-invoke", + status: "success", + }), + ]); }); it("mock 优先命令会直接走 fallback invoke", async () => { diff --git a/src/lib/dev-bridge/safeInvoke.ts b/src/lib/dev-bridge/safeInvoke.ts index cecc92d74..4f06f4c33 100644 --- a/src/lib/dev-bridge/safeInvoke.ts +++ b/src/lib/dev-bridge/safeInvoke.ts @@ -26,8 +26,20 @@ export interface InvokeErrorBufferEntry { args_preview?: Record; } +export interface InvokeTraceBufferEntry { + timestamp: string; + command: string; + transport: "tauri-ipc" | "tauri-legacy" | "http-bridge" | "fallback-invoke"; + status: "success" | "error"; + duration_ms: number; + error?: string; + args_preview?: Record; +} + const INVOKE_ERROR_BUFFER_KEY = "proxycast_invoke_error_buffer_v1"; const INVOKE_ERROR_BUFFER_LIMIT = 120; +const INVOKE_TRACE_BUFFER_KEY = "proxycast_invoke_trace_buffer_v1"; +const INVOKE_TRACE_BUFFER_LIMIT = 240; const INVOKE_ERROR_TEXT_LIMIT = 800; const SECRET_PATTERNS: Array<[RegExp, string]> = [ @@ -127,6 +139,50 @@ function writeInvokeErrorBuffer(items: InvokeErrorBufferEntry[]): void { } } +function readInvokeTraceBuffer(): InvokeTraceBufferEntry[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = window.localStorage.getItem(INVOKE_TRACE_BUFFER_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter( + (item): item is InvokeTraceBufferEntry => + item && + typeof item === "object" && + typeof item.timestamp === "string" && + typeof item.command === "string" && + typeof item.transport === "string" && + (item.status === "success" || item.status === "error") && + typeof item.duration_ms === "number", + ) + .slice(-INVOKE_TRACE_BUFFER_LIMIT); + } catch { + return []; + } +} + +function writeInvokeTraceBuffer(items: InvokeTraceBufferEntry[]): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem( + INVOKE_TRACE_BUFFER_KEY, + JSON.stringify(items.slice(-INVOKE_TRACE_BUFFER_LIMIT)), + ); + } catch { + // ignore + } +} + function recordInvokeError( command: string, args: Record | undefined, @@ -147,6 +203,30 @@ function recordInvokeError( writeInvokeErrorBuffer(current); } +function recordInvokeTrace( + command: string, + args: Record | undefined, + transport: InvokeTraceBufferEntry["transport"], + status: InvokeTraceBufferEntry["status"], + startedAt: number, + error?: unknown, +): void { + const current = readInvokeTraceBuffer(); + const entry: InvokeTraceBufferEntry = { + timestamp: new Date().toISOString(), + command: sanitizeText(command), + transport, + status, + duration_ms: Math.max(0, Date.now() - startedAt), + error: error ? toErrorMessage(error) : undefined, + args_preview: args + ? (sanitizeValue(args) as Record) + : undefined, + }; + current.push(entry); + writeInvokeTraceBuffer(current); +} + export function getInvokeErrorBuffer(limit = 50): InvokeErrorBufferEntry[] { const safeLimit = Number.isFinite(limit) ? Math.min(200, Math.max(1, Math.floor(limit))) @@ -154,6 +234,13 @@ export function getInvokeErrorBuffer(limit = 50): InvokeErrorBufferEntry[] { return readInvokeErrorBuffer().slice(-safeLimit); } +export function getInvokeTraceBuffer(limit = 80): InvokeTraceBufferEntry[] { + const safeLimit = Number.isFinite(limit) + ? Math.min(300, Math.max(1, Math.floor(limit))) + : 80; + return readInvokeTraceBuffer().slice(-safeLimit); +} + export function clearInvokeErrorBuffer(): void { if (typeof window === "undefined") { return; @@ -165,6 +252,17 @@ export function clearInvokeErrorBuffer(): void { } } +export function clearInvokeTraceBuffer(): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.removeItem(INVOKE_TRACE_BUFFER_KEY); + } catch { + // ignore + } +} + /** * 安全的 Tauri invoke 封装 * 支持三种模式:Tauri IPC → HTTP Bridge → Mock。 @@ -174,15 +272,20 @@ export async function safeInvoke( cmd: string, args?: Record, ): Promise { + const startedAt = Date.now(); + // 1. 优先使用 Tauri IPC (生产环境或 Tauri webview 可用时) if ( typeof window !== "undefined" && (window as any).__TAURI__?.core?.invoke ) { try { - return await (window as any).__TAURI__.core.invoke(cmd, args); + const result = await (window as any).__TAURI__.core.invoke(cmd, args); + recordInvokeTrace(cmd, args, "tauri-ipc", "success", startedAt); + return result; } catch (error) { recordInvokeError(cmd, args, error, "tauri-ipc"); + recordInvokeTrace(cmd, args, "tauri-ipc", "error", startedAt, error); throw error; } } @@ -190,9 +293,12 @@ export async function safeInvoke( // Legacy check for older Tauri versions if (typeof window !== "undefined" && (window as any).__TAURI__?.invoke) { try { - return await (window as any).__TAURI__.invoke(cmd, args); + const result = await (window as any).__TAURI__.invoke(cmd, args); + recordInvokeTrace(cmd, args, "tauri-legacy", "success", startedAt); + return result; } catch (error) { recordInvokeError(cmd, args, error, "tauri-legacy"); + recordInvokeTrace(cmd, args, "tauri-legacy", "error", startedAt, error); throw error; } } @@ -200,9 +306,19 @@ export async function safeInvoke( // 2. 浏览器开发模式下,部分原生/非关键命令直接优先走 mock。 if (isDevBridgeAvailable() && shouldPreferMockInBrowser(cmd)) { try { - return await baseInvoke(cmd, args); + const result = await baseInvoke(cmd, args); + recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt); + return result; } catch (error) { recordInvokeError(cmd, args, error, "fallback-invoke"); + recordInvokeTrace( + cmd, + args, + "fallback-invoke", + "error", + startedAt, + error, + ); throw error; } } @@ -211,15 +327,40 @@ export async function safeInvoke( if (isDevBridgeAvailable()) { try { const result = await invokeViaHttp(cmd, args); + recordInvokeTrace(cmd, args, "http-bridge", "success", startedAt); return result as T; } catch (error) { const normalizedError = normalizeDevBridgeError(cmd, error); recordInvokeError(cmd, args, normalizedError, "http-bridge"); + recordInvokeTrace( + cmd, + args, + "http-bridge", + "error", + startedAt, + normalizedError, + ); try { - return await baseInvoke(cmd, args); + const result = await baseInvoke(cmd, args); + recordInvokeTrace( + cmd, + args, + "fallback-invoke", + "success", + startedAt, + ); + return result; } catch (fallbackError) { recordInvokeError(cmd, args, fallbackError, "fallback-invoke"); + recordInvokeTrace( + cmd, + args, + "fallback-invoke", + "error", + startedAt, + fallbackError, + ); throw normalizedError; } } @@ -227,9 +368,12 @@ export async function safeInvoke( // 4. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入) try { - return await baseInvoke(cmd, args); + const result = await baseInvoke(cmd, args); + recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt); + return result; } catch (error) { recordInvokeError(cmd, args, error, "fallback-invoke"); + recordInvokeTrace(cmd, args, "fallback-invoke", "error", startedAt, error); throw error; } }