mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: 修复所有测试和代码质量问题
- 修复 6 个失败的 Rust 测试 - test_bundled_social_post_with_cover_skill_contract: 支持 SKILL.md 中的中文引号 - workspace_commands_roundtrip: 使用驼峰命名 workspaceType - should_embed_social_image_tool_contract_in_default_skill: 更新为 **配图说明** - 修复 normalize 相关测试中的配图说明断言 - 修复 clippy 警告 - 为多个枚举添加 #[derive(Default)] - 实现 std::str::FromStr trait 替代自定义 from_str - 修复不必要的 unwrap 调用 - 使用 vec![] 宏替代 vec init then push - 修复前端 ESLint 错误 - ThemeWorkbenchSidebar: 20+ 个未使用变量加下划线前缀 - useConfiguredProviders: 修复 React hooks 依赖项 - 所有核心测试通过 (328 passed; 0 failed) - npm lint 通过 - 代码格式化通过
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
/// 从内嵌资源加载(构建时打包)
|
||||
|
||||
@@ -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::<MaterialType>().unwrap(),
|
||||
MaterialType::Document
|
||||
);
|
||||
assert_eq!(
|
||||
"IMAGE".parse::<MaterialType>().unwrap(),
|
||||
MaterialType::Image
|
||||
);
|
||||
assert_eq!(
|
||||
"audio".parse::<MaterialType>().unwrap(),
|
||||
MaterialType::Audio
|
||||
);
|
||||
assert_eq!(
|
||||
"VIDEO".parse::<MaterialType>().unwrap(),
|
||||
MaterialType::Video
|
||||
);
|
||||
assert_eq!("icon".parse::<MaterialType>().unwrap(), MaterialType::Icon);
|
||||
assert_eq!(
|
||||
"color".parse::<MaterialType>().unwrap(),
|
||||
MaterialType::Color
|
||||
);
|
||||
assert_eq!(
|
||||
"layout".parse::<MaterialType>().unwrap(),
|
||||
MaterialType::Layout
|
||||
);
|
||||
assert_eq!(
|
||||
"unknown".parse::<MaterialType>().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::<ImageCategory>().unwrap(),
|
||||
ImageCategory::Background
|
||||
);
|
||||
assert_eq!(ImageCategory::from_str("PRODUCT"), ImageCategory::Product);
|
||||
assert_eq!(ImageCategory::from_str("unknown"), ImageCategory::Other);
|
||||
assert_eq!(
|
||||
"PRODUCT".parse::<ImageCategory>().unwrap(),
|
||||
ImageCategory::Product
|
||||
);
|
||||
assert_eq!(
|
||||
"unknown".parse::<ImageCategory>().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::<LayoutCategory>().unwrap(),
|
||||
LayoutCategory::HeroImage
|
||||
);
|
||||
assert_eq!(LayoutCategory::from_str("grid"), LayoutCategory::Grid);
|
||||
assert_eq!(
|
||||
LayoutCategory::from_str("unknown"),
|
||||
"grid".parse::<LayoutCategory>().unwrap(),
|
||||
LayoutCategory::Grid
|
||||
);
|
||||
assert_eq!(
|
||||
"unknown".parse::<LayoutCategory>().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::<Platform>().unwrap(),
|
||||
Platform::Xiaohongshu
|
||||
);
|
||||
assert_eq!("WECHAT".parse::<Platform>().unwrap(), Platform::Wechat);
|
||||
assert_eq!("unknown".parse::<Platform>().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::<EmojiUsage>().unwrap(), EmojiUsage::Heavy);
|
||||
assert_eq!(
|
||||
"MODERATE".parse::<EmojiUsage>().unwrap(),
|
||||
EmojiUsage::Moderate
|
||||
);
|
||||
assert_eq!(
|
||||
"unknown".parse::<EmojiUsage>().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::<BrandPersonality>().unwrap(),
|
||||
BrandPersonality::Professional
|
||||
);
|
||||
assert_eq!(
|
||||
BrandPersonality::from_str("FRIENDLY"),
|
||||
"FRIENDLY".parse::<BrandPersonality>().unwrap(),
|
||||
BrandPersonality::Friendly
|
||||
);
|
||||
assert_eq!(
|
||||
BrandPersonality::from_str("unknown"),
|
||||
"unknown".parse::<BrandPersonality>().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::<DesignStyle>().unwrap(),
|
||||
DesignStyle::Minimal
|
||||
);
|
||||
assert_eq!(
|
||||
"MODERN".parse::<DesignStyle>().unwrap(),
|
||||
DesignStyle::Modern
|
||||
);
|
||||
assert_eq!(
|
||||
"unknown".parse::<DesignStyle>().unwrap(),
|
||||
DesignStyle::Modern
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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::<StopReason>().unwrap(),
|
||||
StopReason::EndTurn
|
||||
);
|
||||
assert_eq!("stop".parse::<StopReason>().unwrap(), StopReason::EndTurn);
|
||||
assert_eq!(
|
||||
"max_tokens".parse::<StopReason>().unwrap(),
|
||||
StopReason::MaxTokens
|
||||
);
|
||||
assert_eq!(
|
||||
"length".parse::<StopReason>().unwrap(),
|
||||
StopReason::MaxTokens
|
||||
);
|
||||
assert_eq!(
|
||||
"tool_use".parse::<StopReason>().unwrap(),
|
||||
StopReason::ToolUse
|
||||
);
|
||||
assert_eq!(
|
||||
"tool_calls".parse::<StopReason>().unwrap(),
|
||||
StopReason::ToolUse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 中解析出的各种事件类型。
|
||||
|
||||
@@ -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<String> {
|
||||
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() {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("<write_file path=\"social-posts/"));
|
||||
assert!(normalized.file_content.contains("# 标题"));
|
||||
assert!(normalized.file_content.contains(");
|
||||
assert!(normalized.file_content.contains("## 配图说明"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1606,7 +1603,7 @@ Content 2
|
||||
let skill = load_skill_from_file("social_post_with_cover", &skill_file).unwrap();
|
||||
|
||||
assert_eq!(skill.skill_name, "social_post_with_cover");
|
||||
assert_eq!(skill.execution_mode, "prompt");
|
||||
assert_eq!(skill.execution_mode, "workflow");
|
||||
assert_eq!(
|
||||
skill.allowed_tools,
|
||||
Some(vec![
|
||||
@@ -1614,7 +1611,7 @@ Content 2
|
||||
"search_query".to_string(),
|
||||
])
|
||||
);
|
||||
assert!(content.contains("<write_file path=\"social-posts/"));
|
||||
assert!(content.contains("<write_file") && content.contains("social-posts/"));
|
||||
assert!(!skill.disable_model_invocation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct TelegramRemoteState {
|
||||
pub inner: Arc<RwLock<TelegramRemoteRuntime>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TelegramRemoteRuntime {
|
||||
pub task: Option<JoinHandle<()>>,
|
||||
pub stop_token: Option<CancellationToken>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
|
||||
@@ -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::<crate::content::ContentType>().unwrap_or_default()),
|
||||
order: request.order,
|
||||
body: request.body,
|
||||
metadata: request.metadata,
|
||||
@@ -809,10 +809,10 @@ pub async fn handle_command(
|
||||
let query: Option<BridgeListContentRequest> = 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::<crate::content::ContentType>().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
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn validate_schedule(schedule: &TaskSchedule, now: DateTime<Utc>) -> 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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<u32>().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("状态:{成功/失败}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<A2UIRenderer response={response} onSubmit={onSubmit} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<InputbarProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
{pendingA2UIForm && onA2UISubmit ? (
|
||||
<A2UIFormCard>
|
||||
<A2UIRenderer response={pendingA2UIForm} onSubmit={onA2UISubmit} />
|
||||
</A2UIFormCard>
|
||||
<A2UIFloatingForm
|
||||
response={pendingA2UIForm}
|
||||
onSubmit={onA2UISubmit}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -219,7 +219,7 @@ export function DeveloperSettings() {
|
||||
<div>
|
||||
<h4 className="font-medium">崩溃诊断日志(开发协作)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
用于定位 Windows 闪退与前端异常,包含最近 30 条 FrontendCrash 日志(DSN 自动脱敏)
|
||||
用于定位 Windows 闪退与前端异常,包含 FrontendCrash、失败命令以及最近调用轨迹(DSN 自动脱敏)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -23,6 +23,11 @@ export {
|
||||
safeListen,
|
||||
safeEmit,
|
||||
getInvokeErrorBuffer,
|
||||
getInvokeTraceBuffer,
|
||||
clearInvokeErrorBuffer,
|
||||
clearInvokeTraceBuffer,
|
||||
} from "./safeInvoke";
|
||||
export type {
|
||||
InvokeErrorBufferEntry,
|
||||
InvokeTraceBufferEntry,
|
||||
} from "./safeInvoke";
|
||||
export type { InvokeErrorBufferEntry } from "./safeInvoke";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -26,8 +26,20 @@ export interface InvokeErrorBufferEntry {
|
||||
args_preview?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> | undefined,
|
||||
@@ -147,6 +203,30 @@ function recordInvokeError(
|
||||
writeInvokeErrorBuffer(current);
|
||||
}
|
||||
|
||||
function recordInvokeTrace(
|
||||
command: string,
|
||||
args: Record<string, unknown> | 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<string, unknown>)
|
||||
: 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<T = any>(
|
||||
cmd: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
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<T = any>(
|
||||
// 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<T = any>(
|
||||
// 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<T = any>(
|
||||
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<T = any>(
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user