From cda14360af6cc015d1fd68f2a1d6cbb7bf3b430e Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 3 Aug 2026 23:24:02 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=BB=BA=E7=AB=8B=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E5=8C=96=E5=9F=BA=E7=BA=BF=EF=BC=88rustfmt=20+=20clippy=20+=20?= =?UTF-8?q?CI=20+=20eslint=20+=20prettier=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置: - rustfmt.toml: 固化 max_width=100 / 4 空格缩进,cargo fmt 全量格式化 - Cargo.toml: 配置 [lints.rust] 与 [lints.clippy] 渐进式规则 - .github/workflows/ci.yml: Rust(fmt+clippy+test) + 前端(eslint+tsc+test) 双平台 CI - Makefile: 新增 check/fmt/fix 目标,clippy 对齐 --all-targets --all-features - web: eslint flat config + prettier 配置 + package.json 脚本与依赖 - src/main.rs: loop→while 修复 clippy::never_loop 对抗性审查发现并修复: - eslint 缺 caughtErrorsIgnorePattern 导致 catch(_) 误报为 error - 前端 lint 未接入 CI,现已补上 Lint 步骤 - Makefile 与 CI 的 clippy flags 不一致,已对齐 --- .github/workflows/ci.yml | 83 + Cargo.toml | 17 + Makefile | 19 +- rustfmt.toml | 12 + src/agent/agent_loop.rs | 169 +- src/agent/context_compressor.rs | 36 +- src/agent/mod.rs | 4 +- src/bootstrap.rs | 2 +- src/bus/message.rs | 57 +- src/channels/cli.rs | 5 +- src/channels/feishu.rs | 73 +- src/channels/manager.rs | 26 +- src/channels/wechat.rs | 42 +- src/cli/init.rs | 58 +- src/cli/mod.rs | 4 +- src/client/mod.rs | 7 +- src/command/adapter.rs | 8 +- src/command/adapters/channel.rs | 2 +- src/command/adapters/cli.rs | 28 +- src/command/adapters/websocket.rs | 112 +- src/command/handler.rs | 4 +- src/command/handlers/delete_topic.rs | 11 +- src/command/handlers/get_current.rs | 19 +- src/command/handlers/help.rs | 7 +- src/command/handlers/list_channels.rs | 2 +- src/command/handlers/list_memories.rs | 5 +- src/command/handlers/list_scheduler_jobs.rs | 5 +- src/command/handlers/list_sessions.rs | 11 +- .../handlers/list_sessions_by_channel.rs | 2 +- src/command/handlers/list_skills.rs | 5 +- src/command/handlers/list_todos.rs | 2 +- src/command/handlers/list_topics.rs | 6 +- src/command/handlers/load_chat_messages.rs | 2 +- src/command/handlers/load_task_messages.rs | 23 +- src/command/handlers/load_topic.rs | 10 +- src/command/handlers/memory_crud.rs | 16 +- src/command/handlers/mod.rs | 13 +- src/command/handlers/rename_topic.rs | 29 +- src/command/handlers/save_session.rs | 88 +- src/command/handlers/save_topic.rs | 38 +- src/command/handlers/session.rs | 26 +- src/command/handlers/stop_execution.rs | 25 +- src/command/handlers/switch_topic.rs | 38 +- src/command/mod.rs | 10 +- src/config/mod.rs | 21 +- src/experts/mod.rs | 163 +- src/frontmatter.rs | 10 +- src/gateway/agent_factory.rs | 63 +- src/gateway/agent_task_executor.rs | 20 +- src/gateway/execution.rs | 114 +- src/gateway/http.rs | 71 +- src/gateway/memory_maintenance.rs | 51 +- src/gateway/mod.rs | 121 +- src/gateway/model_selection.rs | 12 +- src/gateway/processor.rs | 59 +- src/gateway/prompt.rs | 15 +- src/gateway/runtime.rs | 104 +- src/gateway/scheduled_agent_task_service.rs | 5 +- src/gateway/session.rs | 159 +- src/gateway/session_history.rs | 4 +- src/gateway/session_lifecycle.rs | 7 +- src/gateway/session_message_sender.rs | 4 +- src/gateway/session_pool.rs | 35 +- src/gateway/static_files.rs | 10 +- src/gateway/tool_registry_factory.rs | 41 +- src/gateway/ws.rs | 205 ++- src/lib.rs | 2 +- src/logging.rs | 4 +- src/main.rs | 17 +- src/mcp/client.rs | 109 +- src/mcp/config.rs | 53 +- src/mcp/mod.rs | 8 +- src/mcp/tool_adapter.rs | 28 +- src/observability/mod.rs | 3 +- src/platform/mod.rs | 8 +- src/protocol/mod.rs | 21 +- src/protocol/ws_adapter.rs | 36 +- src/providers/anthropic.rs | 14 +- src/providers/openai.rs | 255 ++- src/providers/traits.rs | 2 +- src/scheduler/mod.rs | 216 ++- src/skills/mod.rs | 92 +- src/storage/mod.rs | 52 +- src/storage/records.rs | 39 +- src/storage/row_mapping.rs | 10 +- src/storage/tests.rs | 52 +- src/tools/bash.rs | 50 +- src/tools/calculator.rs | 24 +- src/tools/file_edit.rs | 25 +- src/tools/file_read.rs | 6 +- src/tools/file_write.rs | 23 +- src/tools/memory_manage.rs | 2 +- src/tools/memory_search.rs | 7 +- src/tools/mod.rs | 30 +- src/tools/registry.rs | 13 +- src/tools/session_send.rs | 29 +- src/tools/skill_activate.rs | 17 +- src/tools/task/error.rs | 2 +- src/tools/task/mod.rs | 10 +- src/tools/task/repository.rs | 2 +- src/tools/task/runtime.rs | 267 +-- src/tools/task/tool.rs | 6 +- src/tools/time.rs | 26 +- src/tools/todo_read.rs | 48 +- src/tools/todo_write.rs | 121 +- src/topic_description.rs | 7 +- tests/test_request_format.rs | 4 +- web/.prettierignore | 4 + web/.prettierrc.json | 11 + web/eslint.config.js | 56 + web/package-lock.json | 1432 +++++++++++++++++ web/package.json | 13 +- 112 files changed, 4272 insertions(+), 1439 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 rustfmt.toml create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc.json create mode 100644 web/eslint.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15fe9f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + # 跳过前端构建(build.rs 会触发 npm install + npm run build) + # CI 中独立处理前端检查,避免 cargo check 时重复构建 + SKIP_FRONTEND_BUILD: "1" + +jobs: + rust-checks: + name: Rust fmt + clippy + test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --all -- --check + + # 不使用 -D warnings:现有代码有大量 warn 级别的存量问题 + # (unwrap/expect/clone 等),一上线会让 CI 一直红。 + # 仅 correctness 类别(clippy 默认 deny)会失败,先渐进收集。 + # 等存量清理后再考虑加 -- -D warnings + - name: Clippy + run: cargo clippy --all-targets --all-features + + - name: Build + run: cargo build --lib + + - name: Run tests + run: cargo test --lib + + frontend-checks: + name: Frontend build + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + working-directory: web + run: npm ci + + - name: Lint (eslint) + working-directory: web + run: npm run lint + + - name: Type check + working-directory: web + run: npx tsc --noEmit + + - name: Run tests + working-directory: web + run: npm run test diff --git a/Cargo.toml b/Cargo.toml index cfccdb8..fb95a6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,23 @@ name = "picobot" version = "0.2.0" edition = "2024" +[lints.rust] +# 编译期硬错误:避免明显的内存安全/正确性隐患 +unsafe_op_in_unsafe_fn = "warn" +rust_2018_idioms = "warn" + +[lints.clippy] +# 渐进式策略: +# - 不直接声明 lint group(correctness/suspicious/complexity/perf), +# 因为 lint group 在 [lints] 中需用 priority 语法,简单 = "warn" 会报错; +# 且 clippy 默认已把 correctness 设为 deny,无需重复声明。 +# - 仅显式 warn 少量高价值且存量不大的具体规则,避免一上线淹没在噪音中。 +# 后续随着存量问题清理,可逐步把 unwrap_used/expect_used 升级为 warn。 +redundant_clone = "warn" +dbg_macro = "warn" +print_stderr = "warn" +print_stdout = "warn" + [dependencies] reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] } dotenv = "0.15" diff --git a/Makefile b/Makefile index 6d9f67d..16aa7d2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # PicoBot Web UI Makefile -.PHONY: dev dev-backend dev-frontend build clean install +.PHONY: dev dev-backend dev-frontend build clean install check fmt fix help # Default target all: build @@ -47,11 +47,22 @@ clean: # Check code formatting and linting check: - @echo "Checking frontend..." + @echo "Checking formatting..." + cargo fmt --all -- --check + @echo "Checking frontend (lint + build)..." + cd web && npm run lint cd web && npm run build @echo "Checking Rust code..." cargo check - cargo clippy + cargo clippy --all-targets --all-features + +# Format all Rust code in place +fmt: + cargo fmt --all + +# Auto-fix clippy lints where possible +fix: + cargo clippy --fix --all-targets --allow-dirty --allow-no-vcs # Help help: @@ -66,4 +77,6 @@ help: @echo " make run - Run production build" @echo " make clean - Clean build artifacts" @echo " make check - Check code formatting and linting" + @echo " make fmt - Format all Rust code in place" + @echo " make fix - Auto-fix clippy lints where possible" @echo " make help - Show this help message" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..67ef5a2 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,12 @@ +# PicoBot Rust 代码格式化规则 +# +# 设计原则:尽量贴近 rustfmt 默认风格,仅固化少数项目级偏好。 +# 不追求激进重排,避免一次性产生大量 diff。 +# 仅使用 stable rustfmt 支持的选项,不依赖 nightly 特性。 + +# 行宽:100,现代显示器友好 +max_width = 100 + +# 缩进用 4 空格(Rust 社区主流,与现有代码一致) +hard_tabs = false +tab_spaces = 4 diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 8362d1a..ba826bd 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -2,12 +2,14 @@ use crate::agent::AgentRuntimeConfig; use crate::agent::{SystemPromptContext, SystemPromptProvider}; use crate::bus::ChatMessage; use crate::bus::message::ToolMessageState; -use crate::storage::ConversationRepository; use crate::domain::messages::{ContentBlock, ToolCall}; use crate::observability::{ Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args, }; -use crate::providers::{ChatCompletionRequest, LLMProvider, Message, StreamDelta, StreamCallback, create_provider}; +use crate::providers::{ + ChatCompletionRequest, LLMProvider, Message, StreamCallback, StreamDelta, create_provider, +}; +use crate::storage::ConversationRepository; use crate::text::{char_count, take_prefix_chars, take_suffix_chars}; use crate::tools::{ToolContext, ToolRegistry}; use async_trait::async_trait; @@ -254,7 +256,9 @@ fn filter_images_by_age_and_count( } // 计算这条消息中的图片数量 - let image_count_in_msg = message.media_refs.iter() + let image_count_in_msg = message + .media_refs + .iter() .filter(|p| supported_image_mime_type(p).is_some()) .count(); @@ -284,7 +288,9 @@ fn filter_images_by_age_and_count( // 过滤图片:保留非图片媒体和指定数量的图片 let mut images_kept_in_msg = 0usize; - let filtered_media_refs: Vec = message.media_refs.iter() + let filtered_media_refs: Vec = message + .media_refs + .iter() .filter_map(|path| { if supported_image_mime_type(path).is_some() { if images_kept_in_msg < keep_count { @@ -300,16 +306,22 @@ fn filter_images_by_age_and_count( .collect(); // 如果图片被过滤,添加文本提示 - let original_image_count = message.media_refs.iter() + let original_image_count = message + .media_refs + .iter() .filter(|p| supported_image_mime_type(p).is_some()) .count(); - let filtered_image_count = filtered_media_refs.iter() + let filtered_image_count = filtered_media_refs + .iter() .filter(|p| supported_image_mime_type(p).is_some()) .count(); let content = if original_image_count > filtered_image_count { let notice = if exceeds_age_limit { - format!("{} [图片已过期:超出 {} 条消息范围]", message.content, max_age_rounds) + format!( + "{} [图片已过期:超出 {} 条消息范围]", + message.content, max_age_rounds + ) } else { format!("{} [图片已过期:超出最大图片数量限制]", message.content) }; @@ -705,7 +717,12 @@ impl PersistingEmittedMessageHandler { session_id: impl Into, topic_id: Option, ) -> Self { - Self { inner, conversation_repository, session_id: session_id.into(), topic_id } + Self { + inner, + conversation_repository, + session_id: session_id.into(), + topic_id, + } } } @@ -720,17 +737,15 @@ impl EmittedMessageHandler for PersistingEmittedMessag let topic_id = self.topic_id.clone(); let msg_for_persist = message.clone(); tokio::task::spawn_blocking(move || { - if let Err(e) = repo.append_message_with_topic( - &session_id, - topic_id.as_deref(), - &msg_for_persist, - ) { + if let Err(e) = + repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist) + { tracing::error!(error = %e, session_id = %session_id, "Failed to persist emitted message"); } }) .await - .ok(); // JoinError 不影响主流程 + .ok(); // JoinError 不影响主流程 self.inner.handle(message).await; } @@ -741,11 +756,9 @@ impl EmittedMessageHandler for PersistingEmittedMessag let topic_id = self.topic_id.clone(); let msg_for_persist = message.clone(); tokio::task::spawn_blocking(move || { - if let Err(e) = repo.append_message_with_topic( - &session_id, - topic_id.as_deref(), - &msg_for_persist, - ) { + if let Err(e) = + repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist) + { tracing::error!(error = %e, session_id = %session_id, "Failed to persist emitted message"); } @@ -925,13 +938,15 @@ impl AgentLoop { // Sanitize: remove any trailing incomplete tool call sequences // that may have been persisted before a process interruption. { - let tool_call_ids: Vec<_> = messages.iter() + let tool_call_ids: Vec<_> = messages + .iter() .filter(|m| m.role == "assistant") .filter_map(|m| m.tool_calls.as_ref()) .flatten() .map(|tc| tc.id.clone()) .collect(); - let tool_result_ids: Vec<_> = messages.iter() + let tool_result_ids: Vec<_> = messages + .iter() .filter(|m| m.role == "tool") .filter_map(|m| m.tool_call_id.clone()) .collect(); @@ -982,7 +997,8 @@ impl AgentLoop { if self.check_cancelled().await { tracing::info!(iteration, "Agent execution cancelled by user"); let cancel = Self::build_cancel_result(iteration, emitted_messages); - self.emit_live_tool_call_message(cancel.final_response.clone()).await; + self.emit_live_tool_call_message(cancel.final_response.clone()) + .await; return Ok(cancel); } @@ -1000,7 +1016,12 @@ impl AgentLoop { ); } - let request = self.build_llm_request(&messages, system_prompt_context, tools.clone(), tools_tokens); + let request = self.build_llm_request( + &messages, + system_prompt_context, + tools.clone(), + tools_tokens, + ); // Set up streaming delta consumer // Pre-generate the message ID so stream deltas and the final assistant @@ -1054,7 +1075,10 @@ impl AgentLoop { drop(stream_callback); } else { // 无取消令牌:stream_callback 被 move 进 chat_with_streaming,调用完成即释放。 - llm_result = self.provider.chat_with_streaming(request, stream_callback).await; + llm_result = self + .provider + .chat_with_streaming(request, stream_callback) + .await; } // Close delta channel and wait for consumer to finish processing @@ -1074,7 +1098,8 @@ impl AgentLoop { let assistant_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string())); emitted_messages.push(assistant_message.clone()); - self.emit_live_tool_call_message(assistant_message.clone()).await; + self.emit_live_tool_call_message(assistant_message.clone()) + .await; return Ok(AgentProcessResult { final_response: assistant_message, emitted_messages, @@ -1104,9 +1129,14 @@ impl AgentLoop { // If no tool calls, this is the final response if response.tool_calls.is_empty() { - let result = self.build_final_response( - response, &streaming_message_id, had_streaming, &mut emitted_messages, - ).await; + let result = self + .build_final_response( + response, + &streaming_message_id, + had_streaming, + &mut emitted_messages, + ) + .await; return Ok(result); } @@ -1177,9 +1207,13 @@ impl AgentLoop { }; self.process_tool_results( - &response.tool_calls, &tool_results, &mut loop_detector, - &mut messages, &mut emitted_messages, - ).await; + &response.tool_calls, + &tool_results, + &mut loop_detector, + &mut messages, + &mut emitted_messages, + ) + .await; // Loop continues to next iteration with updated messages // PendingUserAction 工具的结果已在上方加入 messages, @@ -1193,7 +1227,9 @@ impl AgentLoop { } // Max iterations reached - request final summary from LLM - Ok(self.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages).await) + Ok(self + .run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages) + .await) } /// 等待取消信号。若未配置 cancel_token,永远不返回。 @@ -1255,11 +1291,8 @@ impl AgentLoop { &filtered_messages, system_prompt.as_ref().map(|p| p.content.as_str()), ); - let image_tokens = image_token_budget_for_request( - &self.runtime_config, - text_tokens, - tools_tokens, - ); + let image_tokens = + image_token_budget_for_request(&self.runtime_config, text_tokens, tools_tokens); let mut image_budget = ImageInlineBudget::new(image_tokens, image_count); let mut messages_for_llm: Vec = Vec::with_capacity(filtered_messages.len() + 2); @@ -1299,7 +1332,8 @@ impl AgentLoop { assistant_message.id = streaming_message_id.to_string(); } emitted_messages.push(assistant_message.clone()); - self.emit_live_tool_call_message(assistant_message.clone()).await; + self.emit_live_tool_call_message(assistant_message.clone()) + .await; AgentProcessResult { final_response: assistant_message, emitted_messages: std::mem::take(emitted_messages), @@ -1360,7 +1394,10 @@ impl AgentLoop { // Defense: sanitize before final summary request let removed = Self::sanitize_messages_for_llm(messages); if removed > 0 { - tracing::warn!(removed_count = removed, "Sanitized before max-iterations summary"); + tracing::warn!( + removed_count = removed, + "Sanitized before max-iterations summary" + ); } // Add a message asking for summary @@ -1394,13 +1431,15 @@ impl AgentLoop { match final_result { Ok(response) => { - let assistant_message = if let Some(reasoning_content) = response.reasoning_content { + let assistant_message = if let Some(reasoning_content) = response.reasoning_content + { ChatMessage::assistant_with_reasoning(response.content, reasoning_content) } else { ChatMessage::assistant(response.content) }; emitted_messages.push(assistant_message.clone()); - self.emit_live_tool_call_message(assistant_message.clone()).await; + self.emit_live_tool_call_message(assistant_message.clone()) + .await; AgentProcessResult { final_response: assistant_message, emitted_messages: std::mem::take(emitted_messages), @@ -1416,7 +1455,8 @@ impl AgentLoop { ); let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string())); emitted_messages.push(final_message.clone()); - self.emit_live_tool_call_message(final_message.clone()).await; + self.emit_live_tool_call_message(final_message.clone()) + .await; AgentProcessResult { final_response: final_message, emitted_messages: std::mem::take(emitted_messages), @@ -1533,9 +1573,7 @@ impl AgentLoop { // Log function call with name and arguments before execution let args_str = match &tool_call.arguments { serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(), - other => { - serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()) - } + other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()), }; tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool"); @@ -1571,7 +1609,8 @@ impl AgentLoop { Some(t) => t, None => { tracing::warn!(tool = %tool_call.name, "Tool not found"); - let skill_hint = self.skills + let skill_hint = self + .skills .as_ref() .and_then(|s| s.matching_skill_summary(&tool_call.name)); let error = match skill_hint { @@ -1581,10 +1620,7 @@ impl AgentLoop { ), None => format!("Tool '{}' not found", tool_call.name), }; - return ToolExecutionOutcome::failure( - format!("Error: {}", error), - Some(error), - ); + return ToolExecutionOutcome::failure(format!("Error: {}", error), Some(error)); } }; @@ -1977,10 +2013,7 @@ mod tests { // 创建 3 条消息,每条都有图片 let messages: Vec = (0..3) .map(|i| { - ChatMessage::user_with_media( - format!("message {}", i), - vec![jpg_paths[i].clone()], - ) + ChatMessage::user_with_media(format!("message {}", i), vec![jpg_paths[i].clone()]) }) .collect(); @@ -2148,7 +2181,8 @@ mod tests { // Missing tool result for call_2 ]; - let removed_count = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); + let removed_count = + crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); // Phase 1 removes the assistant message (call_2 has no result). // Phase 2 removes the orphaned tool result for call_1 (its parent // assistant was removed). @@ -2181,9 +2215,7 @@ mod tests { fn test_sanitize_removes_orphaned_tool_messages() { // A lone tool message without a preceding assistant tool_calls // is orphaned and should be removed. - let mut messages = vec![ - ChatMessage::tool("call_1", "calculator", "2"), - ]; + let mut messages = vec![ChatMessage::tool("call_1", "calculator", "2")]; let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); assert_eq!(removed, 1); @@ -2398,7 +2430,6 @@ mod tests { ChatMessage::tool("t1_call", "read", "content A"), ChatMessage::assistant("task 1 is done"), // End of task 1 — complete sequence - ChatMessage::user("task 2"), ChatMessage::assistant_with_tool_calls( "doing task 2 — this got interrupted", @@ -2417,7 +2448,6 @@ mod tests { ), // Missing BOTH tool results — process was killed here // End of task 2 — orphaned sequence in the middle - ChatMessage::user("task 3"), ChatMessage::assistant_with_tool_calls( "doing task 3", @@ -2526,11 +2556,19 @@ mod tests { let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); // The assistant should be removed (tool_calls stripped via removal) // and the orphaned tool(A) should also be removed - assert!(removed >= 2, "should remove both the assistant and orphaned tool message, got {}", removed); + assert!( + removed >= 2, + "should remove both the assistant and orphaned tool message, got {}", + removed + ); assert_eq!(messages.len(), 1, "only the user message should remain"); assert_eq!(messages[0].role, "user"); - assert!(messages.iter().all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())), - "no assistant should have tool_calls remaining"); + assert!( + messages + .iter() + .all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())), + "no assistant should have tool_calls remaining" + ); } #[test] @@ -2550,7 +2588,10 @@ mod tests { ]; let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); - assert_eq!(removed, 0, "should not remove anything — tool result immediately follows"); + assert_eq!( + removed, 0, + "should not remove anything — tool result immediately follows" + ); assert_eq!(messages.len(), 3); } } diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index 35ee56e..b190514 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -1,3 +1,4 @@ +use crate::agent::{AgentError, AgentRuntimeConfig}; use crate::bus::{ ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION, SYSTEM_CONTEXT_SCHEDULED_PROMPT, @@ -5,7 +6,6 @@ use crate::bus::{ use crate::config::LLMProviderConfig; use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider}; use crate::text::{char_count, take_prefix_chars}; -use crate::agent::{AgentError, AgentRuntimeConfig}; const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2; const CJK_CHARS_PER_TOKEN: f64 = 2.0; @@ -50,9 +50,9 @@ impl HistoryUnit { /// Estimate tokens for this unit alone. fn estimate_tokens(&self) -> usize { match self { - HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => { - estimate_tokens(std::slice::from_ref(msg)) - } + HistoryUnit::SystemGuard(msg) + | HistoryUnit::UserMessage(msg) + | HistoryUnit::AssistantText(msg) => estimate_tokens(std::slice::from_ref(msg)), HistoryUnit::ToolRound { assistant, results } => { let mut all = vec![assistant.clone()]; all.extend(results.clone()); @@ -162,8 +162,8 @@ pub fn estimate_tokens(messages: &[ChatMessage]) -> usize { } // Weighted token calculation: CJK chars need more tokens per character - let content_tokens = (cjk_count as f64 / CJK_CHARS_PER_TOKEN) - + (other_count as f64 / OTHER_CHARS_PER_TOKEN); + let content_tokens = + (cjk_count as f64 / CJK_CHARS_PER_TOKEN) + (other_count as f64 / OTHER_CHARS_PER_TOKEN); // JSON serialization overhead for message structure (fields, brackets, etc.) let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE; @@ -1114,9 +1114,7 @@ mod tests { #[test] fn test_estimate_tokens_mixed_content() { - let messages = vec![ - ChatMessage::user("Hello 世界 this is 测试"), - ]; + let messages = vec![ChatMessage::user("Hello 世界 this is 测试")]; let tokens = estimate_tokens(&messages); // Content: 18 English chars + 4 CJK chars @@ -1408,8 +1406,12 @@ mod tests { // All units are AssistantText, split at 50% token ratio let split = compressor.find_safe_split_point(&units, 0.5); // Should split somewhere in the middle (not 0, not len()) - assert!(split > 0 && split < units.len(), - "split {} should be between 0 and {}", split, units.len()); + assert!( + split > 0 && split < units.len(), + "split {} should be between 0 and {}", + split, + units.len() + ); } #[test] @@ -1458,10 +1460,14 @@ mod tests { assert_eq!(compressed.len(), 3); // Critical invariant: NO tool_calls or tool_call_id anywhere for msg in &compressed { - assert!(msg.tool_calls.is_none(), - "compress_two_segment output should never contain tool_calls"); - assert!(msg.tool_call_id.is_none(), - "compress_two_segment output should never contain tool_call_id"); + assert!( + msg.tool_calls.is_none(), + "compress_two_segment output should never contain tool_calls" + ); + assert!( + msg.tool_call_id.is_none(), + "compress_two_segment output should never contain tool_call_id" + ); } } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 65e9f28..ce448bc 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -10,6 +10,6 @@ pub use agent_loop::{ pub use context_compressor::ContextCompressor; pub use runtime_config::AgentRuntimeConfig; pub use system_prompt::{ - CompositeSystemPromptProvider, generate_system_env_prompt, SystemPrompt, SystemPromptContext, - SystemPromptProvider, + CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider, + generate_system_env_prompt, }; diff --git a/src/bootstrap.rs b/src/bootstrap.rs index c13a391..27e590d 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -23,4 +23,4 @@ pub fn initialize_process_runtime() { // optionally the RUST_BACKTRACE-based backtrace. default_hook(info); })); -} \ No newline at end of file +} diff --git a/src/bus/message.rs b/src/bus/message.rs index dbd0b38..c8b2472 100644 --- a/src/bus/message.rs +++ b/src/bus/message.rs @@ -25,7 +25,7 @@ pub struct MediaItem { pub mime_type: Option, pub original_key: Option, // Feishu file_key for download pub content_base64: Option, // Base64-encoded file content for web download - pub file_name: Option, // Display file name + pub file_name: Option, // Display file name } impl MediaItem { @@ -284,12 +284,13 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec bool { - matches!(self.event_kind, OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd) + matches!( + self.event_kind, + OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd + ) } pub fn assistant( @@ -548,7 +559,8 @@ impl OutboundMessage { reply_to: Option, metadata: HashMap, ) -> Self { - let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata); + let mut message = + Self::assistant(channel, chat_id, session_id, content, reply_to, metadata); message.event_kind = OutboundEventKind::SchedulerNotification; message } @@ -561,7 +573,8 @@ impl OutboundMessage { reply_to: Option, metadata: HashMap, ) -> Self { - let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata); + let mut message = + Self::assistant(channel, chat_id, session_id, content, reply_to, metadata); message.event_kind = OutboundEventKind::ErrorNotification; message } @@ -595,7 +608,7 @@ impl OutboundMessage { message_id: None, } } - + pub fn tool_result( channel: impl Into, chat_id: impl Into, @@ -626,7 +639,7 @@ impl OutboundMessage { message_id: None, } } - + pub fn tool_pending( channel: impl Into, chat_id: impl Into, @@ -657,7 +670,7 @@ impl OutboundMessage { message_id: None, } } - + /// 构造流式文本增量消息 pub fn stream_delta( channel: impl Into, @@ -685,7 +698,7 @@ impl OutboundMessage { message_id: None, } } - + /// 构造流式结束信号 pub fn stream_end( channel: impl Into, @@ -749,7 +762,8 @@ impl OutboundMessage { "assistant" => { if let Some(tool_calls) = &message.tool_calls { let mut outbound = Vec::new(); - let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some(); + let has_content_or_reasoning = + !message.content.trim().is_empty() || message.reasoning_content.is_some(); if has_content_or_reasoning { let mut resp = Self::assistant( channel.to_string(), @@ -766,7 +780,11 @@ impl OutboundMessage { // AssistantResponse 已携带 reasoning 时,ToolCall 不再重复; // 只有 AssistantResponse 没发时,ToolCall 才带 reasoning - let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() }; + let tc_reasoning = if has_content_or_reasoning { + None + } else { + message.reasoning_content.clone() + }; outbound.extend(tool_calls.iter().map(|tool_call| { let mut tc = Self::tool_call( channel.to_string(), @@ -930,10 +948,7 @@ mod tests { "calculator\nargs: {\"expression\":\"1 + 1\"}" ); assert_eq!(outbound[1].tool_name.as_deref(), Some("read")); - assert_eq!( - outbound[1].content, - "read\nargs: {\"path\":\"README.md\"}" - ); + assert_eq!(outbound[1].content, "read\nargs: {\"path\":\"README.md\"}"); } #[test] diff --git a/src/channels/cli.rs b/src/channels/cli.rs index 411ad4e..b103f41 100644 --- a/src/channels/cli.rs +++ b/src/channels/cli.rs @@ -84,7 +84,10 @@ impl Channel for CliChannel { self.shutdown_token.cancel(); let count = self.connections.read().await.len(); self.connections.write().await.clear(); - tracing::info!(connection_count = count, "CliChannel stopped, all connections signaled to close"); + tracing::info!( + connection_count = count, + "CliChannel stopped, all connections signaled to close" + ); Ok(()) } diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 6d7484f..1132f2b 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -10,8 +10,8 @@ use regex::Regex; use serde::Deserialize; use tokio::sync::{RwLock, broadcast}; -use crate::bus::{MediaItem, MessageBus, OutboundMessage}; use crate::bus::message::OutboundEventKind; +use crate::bus::{MediaItem, MessageBus, OutboundMessage}; use crate::channels::base::{Channel, ChannelError}; use crate::config::{FeishuChannelConfig, LLMProviderConfig}; use crate::text::{char_count, truncate_with_ellipsis}; @@ -548,9 +548,10 @@ impl FeishuChannel { } let status = resp.status(); - let body = resp.text().await.map_err(|e| { - ChannelError::Other(format!("Read upload image response error: {}", e)) - })?; + let body = resp + .text() + .await + .map_err(|e| ChannelError::Other(format!("Read upload image response error: {}", e)))?; let result: UploadResp = serde_json::from_str(&body).map_err(|e| { ChannelError::Other(format!( "Parse upload image response error: {} (status={}, body={})", @@ -631,9 +632,10 @@ impl FeishuChannel { } let status = resp.status(); - let body = resp.text().await.map_err(|e| { - ChannelError::Other(format!("Read upload file response error: {}", e)) - })?; + let body = resp + .text() + .await + .map_err(|e| ChannelError::Other(format!("Read upload file response error: {}", e)))?; let result: UploadResp = serde_json::from_str(&body).map_err(|e| { ChannelError::Other(format!( "Parse upload file response error: {} (status={}, body={})", @@ -982,9 +984,11 @@ impl FeishuChannel { reply_to: Option<&str>, ) -> Result<(), ChannelError> { if let Some(parent_id) = reply_to { - self.reply_to_feishu_message(parent_id, msg_type, content).await + self.reply_to_feishu_message(parent_id, msg_type, content) + .await } else { - self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content).await + self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content) + .await } } @@ -1440,16 +1444,20 @@ fn parse_post_content(content: &str) -> String { } "code_block" => { let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or(""); - let code_text = if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) { - content_arr - .iter() - .filter_map(|item| item.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("") - } else { - // Fallback to text field for backwards compatibility - el.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string() - }; + let code_text = + if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) { + content_arr + .iter() + .filter_map(|item| item.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("") + } else { + // Fallback to text field for backwards compatibility + el.get("text") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string() + }; out.push(format!("\n```{}\n{}\n```\n", lang, code_text)); } _ => { @@ -2380,7 +2388,8 @@ mod tests { #[test] fn parse_post_content_handles_empty_code_block() { // Test code_block with empty content - let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#; + let post_json = + r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#; let result = parse_post_content(post_json); assert!(result.contains("```go")); } @@ -2461,8 +2470,18 @@ impl Channel for FeishuChannel { } async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { - if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted) - || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false) + if matches!( + msg.event_kind, + OutboundEventKind::ToolResult + | OutboundEventKind::ToolPending + | OutboundEventKind::StreamDelta + | OutboundEventKind::StreamEnd + | OutboundEventKind::ExecutionCompleted + ) || msg + .metadata + .get("is_subagent_event") + .map(|v| v == "true") + .unwrap_or(false) { return Ok(()); } @@ -2553,8 +2572,14 @@ impl Channel for FeishuChannel { } if !msg.content.trim().is_empty() { - self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to) - .await?; + self.dispatch_send( + receive_id, + receive_id_type, + "text", + msg.content.trim(), + reply_to, + ) + .await?; } let mut sent_media = 0usize; diff --git a/src/channels/manager.rs b/src/channels/manager.rs index 396103e..b480b1c 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -48,7 +48,9 @@ impl ChannelManager { ) -> Result<(), ChannelError> { for (name, channel_config) in &config.channels { match channel_config { - crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(feishu_config)) + crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu( + feishu_config, + )) | crate::config::ChannelConfig::LegacyFeishu(feishu_config) => { if feishu_config.enabled { let channel = FeishuChannel::new( @@ -72,7 +74,9 @@ impl ChannelManager { tracing::info!(channel = %name, kind = channel_config.kind(), "Channel disabled in config"); } } - crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(wechat_config)) => { + crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat( + wechat_config, + )) => { if wechat_config.enabled { let channel = WechatChannel::new( name.clone(), @@ -253,8 +257,14 @@ mod tests { names.sort(); assert_eq!(names, vec!["backup", "primary", "websocket"]); - assert_eq!(manager.get_channel("primary").await.unwrap().name(), "primary"); - assert_eq!(manager.get_channel("backup").await.unwrap().name(), "backup"); + assert_eq!( + manager.get_channel("primary").await.unwrap().name(), + "primary" + ); + assert_eq!( + manager.get_channel("backup").await.unwrap().name(), + "backup" + ); } #[tokio::test] @@ -295,7 +305,8 @@ mod tests { "cred_path": "" } } -}"#.replace("", &cred_path_json), +}"# + .replace("", &cred_path_json), ) .unwrap(); @@ -314,6 +325,9 @@ mod tests { names.sort(); assert_eq!(names, vec!["websocket", "wechat_main"]); - assert_eq!(manager.get_channel("wechat_main").await.unwrap().name(), "wechat_main"); + assert_eq!( + manager.get_channel("wechat_main").await.unwrap().name(), + "wechat_main" + ); } } diff --git a/src/channels/wechat.rs b/src/channels/wechat.rs index 2664cf9..19fda36 100644 --- a/src/channels/wechat.rs +++ b/src/channels/wechat.rs @@ -13,8 +13,8 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use wechatbot::{BotOptions, SendContent, WeChatBot}; -use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage}; use crate::bus::message::OutboundEventKind; +use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage}; use crate::channels::base::{Channel, ChannelError}; use crate::config::{LLMProviderConfig, WechatChannelConfig}; @@ -55,7 +55,10 @@ impl WechatChannel { } fn sender_allowed(&self, sender_id: &str) -> bool { - self.config.allow_from.iter().any(|pattern| pattern == "*" || pattern == sender_id) + self.config + .allow_from + .iter() + .any(|pattern| pattern == "*" || pattern == sender_id) } fn media_to_send_content( @@ -132,14 +135,17 @@ impl WechatChannel { ) -> Result, ChannelError> { let Some(downloaded) = bot.download(&msg).await.map_err(|error| { ChannelError::Other(format!("WeChat media download failed: {}", error)) - })? else { + })? + else { return Ok(Vec::new()); }; let media_dir = Self::default_media_dir(); tokio::fs::create_dir_all(&media_dir) .await - .map_err(|error| ChannelError::Other(format!("Failed to create WeChat media dir: {}", error)))?; + .map_err(|error| { + ChannelError::Other(format!("Failed to create WeChat media dir: {}", error)) + })?; let filename = Self::build_download_filename( &downloaded.media_type, @@ -149,7 +155,9 @@ impl WechatChannel { let file_path = media_dir.join(&filename); tokio::fs::write(&file_path, downloaded.data) .await - .map_err(|error| ChannelError::Other(format!("Failed to write WeChat media file: {}", error)))?; + .map_err(|error| { + ChannelError::Other(format!("Failed to write WeChat media file: {}", error)) + })?; tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media"); @@ -316,7 +324,11 @@ impl Channel for WechatChannel { | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted - ) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false) + ) || msg + .metadata + .get("is_subagent_event") + .map(|v| v == "true") + .unwrap_or(false) { return Ok(()); } @@ -344,9 +356,12 @@ impl Channel for WechatChannel { None }; let content = Self::media_to_send_content(media, caption)?; - self.bot.send_media(&msg.chat_id, content).await.map_err(|error| { - ChannelError::SendError(format!("WeChat media send failed: {}", error)) - })?; + self.bot + .send_media(&msg.chat_id, content) + .await + .map_err(|error| { + ChannelError::SendError(format!("WeChat media send failed: {}", error)) + })?; tracing::info!( channel = %self.name, chat_id = %msg.chat_id, @@ -409,13 +424,12 @@ mod tests { std::fs::rename(file.path(), &doc_path).unwrap(); let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file"); - let content = WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap(); + let content = + WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap(); match content { SendContent::File { - file_name, - caption, - .. + file_name, caption, .. } => { assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy()); assert_eq!(caption.as_deref(), Some("note")); @@ -423,4 +437,4 @@ mod tests { _ => panic!("expected file send content"), } } -} \ No newline at end of file +} diff --git a/src/cli/init.rs b/src/cli/init.rs index 30fa849..88e2679 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -117,7 +117,11 @@ impl InitWizard { } let input = line.trim().to_string(); - Ok(if input.is_empty() { default.to_string() } else { input }) + Ok(if input.is_empty() { + default.to_string() + } else { + input + }) } async fn prompt_required(&mut self, label: &str) -> Result { @@ -154,9 +158,9 @@ impl InitWizard { let default_str = (default + 1).to_string(); let input = self.prompt_with_default(label, &default_str).await?; - let selected: usize = input.parse().map_err(|_| { - InitError::InputError(format!("Invalid selection: {}", input)) - })?; + let selected: usize = input + .parse() + .map_err(|_| InitError::InputError(format!("Invalid selection: {}", input)))?; if selected == 0 || selected > options.len() { return Err(InitError::InputError(format!( @@ -195,9 +199,7 @@ impl InitWizard { println!(" 4. Skip"); println!(); - let choice = self - .prompt_with_default("Select option", "1") - .await?; + let choice = self.prompt_with_default("Select option", "1").await?; match choice.as_str() { "1" => return self.add_provider(existing).await, @@ -243,9 +245,7 @@ impl InitWizard { &mut self, existing: &Config, ) -> Result, InitError> { - let provider_name = self - .prompt_with_default("Provider name", "default") - .await?; + let provider_name = self.prompt_with_default("Provider name", "default").await?; println!("Provider type:"); println!(" 1. openai"); @@ -307,7 +307,9 @@ impl InitWizard { }; let type_options = vec!["openai".to_string(), "anthropic".to_string()]; println!("Provider type:"); - let type_idx = self.prompt_select("", &type_options, current_type_idx).await?; + let type_idx = self + .prompt_select("", &type_options, current_type_idx) + .await?; let provider_type = &type_options[type_idx]; let base_url = self @@ -536,9 +538,7 @@ impl InitWizard { providers: &HashMap, models: &HashMap, ) -> Result, InitError> { - let agent_name = self - .prompt_with_default("Agent name", "default") - .await?; + let agent_name = self.prompt_with_default("Agent name", "default").await?; // Select provider let provider_names: Vec = providers.keys().cloned().collect(); @@ -600,7 +600,9 @@ impl InitWizard { .position(|p| p == ¤t_agent.provider) .unwrap_or(0); println!("Select provider:"); - let provider_idx = self.prompt_select("", &provider_names, current_provider_idx).await?; + let provider_idx = self + .prompt_select("", &provider_names, current_provider_idx) + .await?; let selected_provider = &provider_names[provider_idx]; // Select new model @@ -611,7 +613,9 @@ impl InitWizard { .unwrap_or(0); println!(); println!("Select model:"); - let model_idx = self.prompt_select("", &model_names, current_model_idx).await?; + let model_idx = self + .prompt_select("", &model_names, current_model_idx) + .await?; let selected_model = &model_names[model_idx]; let agent = AgentConfig { @@ -650,7 +654,11 @@ impl InitWizard { if !existing.channels.is_empty() { println!("Existing channels:"); for (name, config) in &existing.channels { - let status = if config.enabled() { "enabled" } else { "disabled" }; + let status = if config.enabled() { + "enabled" + } else { + "disabled" + }; println!(" - {} ({})", name, status); } println!(); @@ -700,9 +708,7 @@ impl InitWizard { println!("Configuring Feishu channel..."); println!(); - let channel_name = self - .prompt_with_default("Channel name", "feishu") - .await?; + let channel_name = self.prompt_with_default("Channel name", "feishu").await?; let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu()); @@ -766,7 +772,8 @@ impl InitWizard { println!(); println!("Starting WeChat login..."); - self.do_wechat_login(base_url, &Self::default_wechat_cred_path()).await?; + self.do_wechat_login(base_url, &Self::default_wechat_cred_path()) + .await?; println!(); println!("WeChat login successful! Credentials saved."); @@ -796,9 +803,10 @@ impl InitWizard { })), }); - let creds = bot.login(true).await.map_err(|e| { - InitError::WeChatError(format!("WeChat login failed: {}", e)) - })?; + let creds = bot + .login(true) + .await + .map_err(|e| InitError::WeChatError(format!("WeChat login failed: {}", e)))?; println!(); println!( @@ -925,4 +933,4 @@ impl From for InitError { fn from(e: std::io::Error) -> Self { InitError::IoError(e.to_string()) } -} \ No newline at end of file +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index aff318c..07ec1dc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,7 @@ pub mod channel; -pub mod input; pub mod init; +pub mod input; pub use channel::CliChannel; -pub use input::{InputCommand, InputEvent, InputHandler}; pub use init::InitWizard; +pub use input::{InputCommand, InputEvent, InputHandler}; diff --git a/src/client/mod.rs b/src/client/mod.rs index 4f66097..dd39afe 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -26,8 +26,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { let mut current_session_id: Option = None; // Track message IDs that were already streamed so we can skip // the duplicate AssistantResponse that arrives afterwards. - let mut streamed_message_ids: std::collections::HashSet = std::collections::HashSet::new(); - input.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n").await?; + let mut streamed_message_ids: std::collections::HashSet = + std::collections::HashSet::new(); + input + .write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n") + .await?; // Main loop: poll both stdin and WebSocket loop { diff --git a/src/command/adapter.rs b/src/command/adapter.rs index e8a9686..0fe4619 100644 --- a/src/command/adapter.rs +++ b/src/command/adapter.rs @@ -1,6 +1,6 @@ +use crate::command::Command; use crate::command::context::AdapterContext; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; /// 输入适配器:将渠道特定输入转换为 Command /// @@ -13,11 +13,7 @@ pub trait InputAdapter: Send + Sync { /// - `Ok(Some(Command))`:成功解析为命令 /// - `Ok(None)`:不是命令(如普通聊天消息) /// - `Err(CommandError)`:解析错误(如缺少参数) - fn try_parse( - &self, - input: &str, - ctx: AdapterContext, - ) -> Result, AdapterError>; + fn try_parse(&self, input: &str, ctx: AdapterContext) -> Result, AdapterError>; } /// 输出适配器:将 CommandResponse 转换为渠道特定输出 diff --git a/src/command/adapters/channel.rs b/src/command/adapters/channel.rs index d89e20b..5fb5a0f 100644 --- a/src/command/adapters/channel.rs +++ b/src/command/adapters/channel.rs @@ -1,6 +1,6 @@ +use crate::command::Command; use crate::command::adapter::{AdapterError, InputAdapter}; use crate::command::context::AdapterContext; -use crate::command::Command; /// Channel 输入适配器 /// diff --git a/src/command/adapters/cli.rs b/src/command/adapters/cli.rs index 9a4544c..dcb9184 100644 --- a/src/command/adapters/cli.rs +++ b/src/command/adapters/cli.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter}; use crate::command::context::AdapterContext; use crate::command::response::{CommandResponse, MessageKind}; -use crate::command::Command; /// CLI 输入适配器 /// @@ -313,7 +313,14 @@ mod tests { assert!(result.is_some()); let cmd = result.unwrap(); - assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: false, .. })); + assert!(matches!( + cmd, + Command::SaveSession { + filepath: None, + include_all: false, + .. + } + )); } #[test] @@ -321,7 +328,9 @@ mod tests { let adapter = CliInputAdapter::new(); let ctx = AdapterContext::new("test"); - let result = adapter.try_parse("/save-session ./debug/session.md", ctx).unwrap(); + let result = adapter + .try_parse("/save-session ./debug/session.md", ctx) + .unwrap(); assert!(result.is_some()); let cmd = result.unwrap(); @@ -344,7 +353,14 @@ mod tests { assert!(result.is_some()); let cmd = result.unwrap(); - assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: true, .. })); + assert!(matches!( + cmd, + Command::SaveSession { + filepath: None, + include_all: true, + .. + } + )); } #[test] @@ -352,7 +368,9 @@ mod tests { let adapter = CliInputAdapter::new(); let ctx = AdapterContext::new("test"); - let result = adapter.try_parse("/save-session all ./debug/session.md", ctx).unwrap(); + let result = adapter + .try_parse("/save-session all ./debug/session.md", ctx) + .unwrap(); assert!(result.is_some()); let cmd = result.unwrap(); diff --git a/src/command/adapters/websocket.rs b/src/command/adapters/websocket.rs index 739d4a6..676cc3a 100644 --- a/src/command/adapters/websocket.rs +++ b/src/command/adapters/websocket.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter}; use crate::command::context::AdapterContext; use crate::command::response::{CommandResponse, MessageKind}; -use crate::command::Command; use crate::protocol::WsOutbound; /// WebSocket 输入适配器 @@ -79,8 +79,12 @@ impl OutputAdapter for WebSocketOutputAdapter { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, MessageKind::Notification => { // 根据元数据判断具体类型 @@ -90,9 +94,13 @@ impl OutputAdapter for WebSocketOutputAdapter { response.metadata.get("topic_id"), response.metadata.get("title"), ) { - match serde_json::from_str::>(topics_json) { + match serde_json::from_str::>( + topics_json, + ) { Ok(topics) => { - let session_id = response.metadata.get("session_id") + let session_id = response + .metadata + .get("session_id") .cloned() .unwrap_or_default(); WsOutbound::TopicRenamed { @@ -106,28 +114,37 @@ impl OutputAdapter for WebSocketOutputAdapter { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, } } else if let Some(topics_json) = response.metadata.get("topics") { // Topic 列表响应 - 优先检查 topics - match serde_json::from_str::>(topics_json) { + match serde_json::from_str::>( + topics_json, + ) { Ok(topics) => { - let session_id = response.metadata.get("session_id") + let session_id = response + .metadata + .get("session_id") .cloned() .unwrap_or_default(); - WsOutbound::TopicList { - topics, - session_id, - } + WsOutbound::TopicList { topics, session_id } } Err(_) => WsOutbound::AssistantResponse { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, } } else if let Some(session_id) = response.metadata.get("session_id") { @@ -139,7 +156,9 @@ impl OutputAdapter for WebSocketOutputAdapter { } } else { // 加载会话 - let message_count = response.metadata.get("message_count") + let message_count = response + .metadata + .get("message_count") .and_then(|s| s.parse().ok()) .unwrap_or(0); WsOutbound::SessionLoaded { @@ -150,7 +169,9 @@ impl OutputAdapter for WebSocketOutputAdapter { } } else if let Some(topic_id) = response.metadata.get("topic_id") { // 只有 topic_id,可能是加载话题 - let message_count = response.metadata.get("message_count") + let message_count = response + .metadata + .get("message_count") .and_then(|s| s.parse().ok()) .unwrap_or(0); WsOutbound::SessionLoaded { @@ -166,13 +187,19 @@ impl OutputAdapter for WebSocketOutputAdapter { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, } } else if let Some(sessions_json) = response.metadata.get("sessions") { // 会话列表响应 - match serde_json::from_str::>(sessions_json) { + match serde_json::from_str::>( + sessions_json, + ) { Ok(sessions) => { let channel_name = response.metadata.get("channel_name").cloned(); WsOutbound::SessionList { @@ -185,28 +212,37 @@ impl OutputAdapter for WebSocketOutputAdapter { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, } } else if let Some(topics_json) = response.metadata.get("topics") { // Topic 列表响应 - match serde_json::from_str::>(topics_json) { + match serde_json::from_str::>( + topics_json, + ) { Ok(topics) => { - let session_id = response.metadata.get("session_id") + let session_id = response + .metadata + .get("session_id") .cloned() .unwrap_or_default(); - WsOutbound::TopicList { - topics, - session_id, - } + WsOutbound::TopicList { topics, session_id } } Err(_) => WsOutbound::AssistantResponse { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, } } else { @@ -215,8 +251,12 @@ impl OutputAdapter for WebSocketOutputAdapter { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, } } } @@ -230,8 +270,12 @@ impl OutputAdapter for WebSocketOutputAdapter { id: response.request_id.to_string(), content: msg.content.clone(), role: "assistant".to_string(), - attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), - reasoning_content: None, user_message_id: None, + attachments: Vec::new(), + subagent_task_id: None, + topic_id: None, + timestamp: Some(crate::protocol::now_timestamp()), + reasoning_content: None, + user_message_id: None, }, }; outbounds.push(outbound); diff --git a/src/command/handler.rs b/src/command/handler.rs index e216a3c..b6be9f7 100644 --- a/src/command/handler.rs +++ b/src/command/handler.rs @@ -1,8 +1,8 @@ +use crate::agent::AgentError; use crate::bus::InboundMessage; +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; -use crate::agent::AgentError; use crate::gateway::session::SessionManager; use async_trait::async_trait; use std::sync::Arc; diff --git a/src/command/handlers/delete_topic.rs b/src/command/handlers/delete_topic.rs index fabe5fa..1d1e452 100644 --- a/src/command/handlers/delete_topic.rs +++ b/src/command/handlers/delete_topic.rs @@ -1,8 +1,8 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handlers::list_topics::TopicSummary; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::gateway::session::SessionManager; use crate::storage::SessionStore; use async_trait::async_trait; @@ -100,9 +100,8 @@ async fn handle_delete_topic( }) .collect(); - let topics_json = - serde_json::to_string(&topic_summaries) - .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; + let topics_json = serde_json::to_string(&topic_summaries) + .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; let message = format!("✓ 已删除话题: {}", topic_title); @@ -130,9 +129,7 @@ mod tests { // 先创建 session 和 topic let session = store.create_session("test_channel", Some("test")).unwrap(); - let topic = store - .create_topic(&session.id, "test topic", None) - .unwrap(); + let topic = store.create_topic(&session.id, "test topic", None).unwrap(); let ctx = CommandContext::new("test", "test_channel") .with_session_id(&session.id) diff --git a/src/command/handlers/get_current.rs b/src/command/handlers/get_current.rs index baf5145..9fa0d05 100644 --- a/src/command/handlers/get_current.rs +++ b/src/command/handlers/get_current.rs @@ -1,9 +1,9 @@ use crate::agent::context_compressor::estimate_tokens; use crate::agent::{SystemPromptContext, SystemPromptProvider}; +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::SessionStore; use async_trait::async_trait; use std::sync::Arc; @@ -58,17 +58,23 @@ async fn handle_get_current_session( handler: &GetCurrentSessionCommandHandler, ctx: CommandContext, ) -> Result { - let topic_id = ctx.topic_id.as_deref() + let topic_id = ctx + .topic_id + .as_deref() .ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?; - let chat_id = ctx.chat_id.as_deref() + let chat_id = ctx + .chat_id + .as_deref() .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?; let topic = handler .store .get_topic(topic_id) .map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; + .ok_or_else(|| { + CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)) + })?; // 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史) let messages = handler @@ -88,7 +94,8 @@ async fn handle_get_current_session( user_message_count, }; - provider.build(&system_prompt_context) + provider + .build(&system_prompt_context) .map(|sp| { use crate::bus::ChatMessage; let system_msg = ChatMessage::system(&sp.content); @@ -155,4 +162,4 @@ fn format_time_ago(timestamp_ms: i64) -> String { } else { format!("{} days ago", diff_secs / 86400) } -} \ No newline at end of file +} diff --git a/src/command/handlers/help.rs b/src/command/handlers/help.rs index e929881..03b80e2 100644 --- a/src/command/handlers/help.rs +++ b/src/command/handlers/help.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use async_trait::async_trait; use std::sync::{Arc, Mutex}; @@ -44,8 +44,7 @@ impl CommandHandler for HelpCommandHandler { let metadata = self.metadata.lock().unwrap(); let help_text = format_help(&metadata); - Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Text, &help_text)) + Ok(CommandResponse::success(ctx.request_id).with_message(MessageKind::Text, &help_text)) } } @@ -58,4 +57,4 @@ fn format_help(commands: &[CommandMetadata]) -> String { } output -} \ No newline at end of file +} diff --git a/src/command/handlers/list_channels.rs b/src/command/handlers/list_channels.rs index d9d9fd7..621d37b 100644 --- a/src/command/handlers/list_channels.rs +++ b/src/command/handlers/list_channels.rs @@ -1,8 +1,8 @@ use crate::channels::manager::ChannelManager; +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use async_trait::async_trait; use std::sync::Arc; diff --git a/src/command/handlers/list_memories.rs b/src/command/handlers/list_memories.rs index d6cc18c..e0b7c32 100644 --- a/src/command/handlers/list_memories.rs +++ b/src/command/handlers/list_memories.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; use crate::storage::SessionStore; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -68,7 +68,6 @@ impl CommandHandler for ListMemoriesCommandHandler { let memories_json = serde_json::to_string(&summaries) .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; - Ok(CommandResponse::success(ctx.request_id) - .with_metadata("memories", &memories_json)) + Ok(CommandResponse::success(ctx.request_id).with_metadata("memories", &memories_json)) } } diff --git a/src/command/handlers/list_scheduler_jobs.rs b/src/command/handlers/list_scheduler_jobs.rs index 02332e3..2e6e04a 100644 --- a/src/command/handlers/list_scheduler_jobs.rs +++ b/src/command/handlers/list_scheduler_jobs.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary}; use crate::storage::SessionStore; use async_trait::async_trait; @@ -67,8 +67,7 @@ impl CommandHandler for ListSchedulerJobsCommandHandler { let jobs_json = serde_json::to_string(&summaries) .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; - Ok(CommandResponse::success(ctx.request_id) - .with_metadata("scheduler_jobs", &jobs_json)) + Ok(CommandResponse::success(ctx.request_id).with_metadata("scheduler_jobs", &jobs_json)) } } diff --git a/src/command/handlers/list_sessions.rs b/src/command/handlers/list_sessions.rs index d7bf0dc..9514847 100644 --- a/src/command/handlers/list_sessions.rs +++ b/src/command/handlers/list_sessions.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::SessionStore; use async_trait::async_trait; use std::sync::Arc; @@ -50,7 +50,9 @@ async fn handle_list_sessions( _include_archived: bool, ctx: CommandContext, ) -> Result { - let session_id = ctx.session_id.as_deref() + let session_id = ctx + .session_id + .as_deref() .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; let topics = handler @@ -72,7 +74,8 @@ async fn handle_list_sessions( let marker = if is_current { " *" } else { "" }; // 使用辅助方法获取消息数量 - let msg_count = handler.store + let msg_count = handler + .store .get_topic_message_count(&topic.id) .unwrap_or(0); @@ -104,4 +107,4 @@ async fn handle_list_sessions( .with_metadata("topics", &topics_json) .with_metadata("count", &topics.len().to_string()) .with_metadata("current_topic_id", current_topic_id)) -} \ No newline at end of file +} diff --git a/src/command/handlers/list_sessions_by_channel.rs b/src/command/handlers/list_sessions_by_channel.rs index 992eb66..b3dd703 100644 --- a/src/command/handlers/list_sessions_by_channel.rs +++ b/src/command/handlers/list_sessions_by_channel.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::protocol::SessionSummary; use crate::storage::SessionStore; use async_trait::async_trait; diff --git a/src/command/handlers/list_skills.rs b/src/command/handlers/list_skills.rs index f8ce5a3..3f447ec 100644 --- a/src/command/handlers/list_skills.rs +++ b/src/command/handlers/list_skills.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; use crate::skills::SkillRuntime; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -58,7 +58,6 @@ impl CommandHandler for ListSkillsCommandHandler { let skills_json = serde_json::to_string(&summaries) .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; - Ok(CommandResponse::success(ctx.request_id) - .with_metadata("skills", &skills_json)) + Ok(CommandResponse::success(ctx.request_id).with_metadata("skills", &skills_json)) } } diff --git a/src/command/handlers/list_todos.rs b/src/command/handlers/list_todos.rs index 3b837ca..1484e79 100644 --- a/src/command/handlers/list_todos.rs +++ b/src/command/handlers/list_todos.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; use crate::protocol::TodoItemSummary; use crate::storage::SessionStore; use async_trait::async_trait; diff --git a/src/command/handlers/list_topics.rs b/src/command/handlers/list_topics.rs index 29088e8..e99bc5a 100644 --- a/src/command/handlers/list_topics.rs +++ b/src/command/handlers/list_topics.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::SessionStore; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -50,9 +50,7 @@ impl CommandHandler for ListTopicsCommandHandler { ctx: CommandContext, ) -> Result { match cmd { - Command::ListTopics { session_id } => { - handle_list_topics(self, session_id, ctx).await - } + Command::ListTopics { session_id } => handle_list_topics(self, session_id, ctx).await, _ => unreachable!(), } } diff --git a/src/command/handlers/load_chat_messages.rs b/src/command/handlers/load_chat_messages.rs index e312a50..226b222 100644 --- a/src/command/handlers/load_chat_messages.rs +++ b/src/command/handlers/load_chat_messages.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; use async_trait::async_trait; /// 加载指定 channel + chat_id 的对话消息。 diff --git a/src/command/handlers/load_task_messages.rs b/src/command/handlers/load_task_messages.rs index 5ce620a..798af18 100644 --- a/src/command/handlers/load_task_messages.rs +++ b/src/command/handlers/load_task_messages.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; use crate::storage::SessionStore; use crate::tools::task::repository::TaskRepository; use crate::tools::task::types::{TaskSession, TaskSessionState}; @@ -14,11 +14,11 @@ pub struct LoadTaskMessagesCommandHandler { } impl LoadTaskMessagesCommandHandler { - pub fn new( - task_repository: Arc, - store: Arc, - ) -> Self { - Self { task_repository, store } + pub fn new(task_repository: Arc, store: Arc) -> Self { + Self { + task_repository, + store, + } } } @@ -62,11 +62,7 @@ async fn handle_load_task_messages( ); // 1. Try in-memory repository first - let task = match handler - .task_repository - .load_task_session(&task_id) - .await - { + let task = match handler.task_repository.load_task_session(&task_id).await { Ok(Some(task)) => { tracing::info!( task_id = %task.id, @@ -186,6 +182,9 @@ fn parse_subagent_title(title: &str) -> (String, String) { return (agent_type, desc); } } - let desc = title.strip_prefix("Subagent: ").unwrap_or(title).to_string(); + let desc = title + .strip_prefix("Subagent: ") + .unwrap_or(title) + .to_string(); ("general".to_string(), desc) } diff --git a/src/command/handlers/load_topic.rs b/src/command/handlers/load_topic.rs index 5a85966..d0dc62d 100644 --- a/src/command/handlers/load_topic.rs +++ b/src/command/handlers/load_topic.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::SessionStore; use async_trait::async_trait; use std::sync::Arc; @@ -37,9 +37,7 @@ impl CommandHandler for LoadTopicCommandHandler { ctx: CommandContext, ) -> Result { match cmd { - Command::LoadTopic { topic_id } => { - handle_load_topic(self, topic_id, ctx).await - } + Command::LoadTopic { topic_id } => handle_load_topic(self, topic_id, ctx).await, _ => unreachable!(), } } @@ -54,7 +52,9 @@ async fn handle_load_topic( .store .get_topic(&topic_id) .map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; + .ok_or_else(|| { + CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)) + })?; Ok(CommandResponse::success(ctx.request_id) .with_message(MessageKind::Notification, &topic.title) diff --git a/src/command/handlers/memory_crud.rs b/src/command/handlers/memory_crud.rs index 3439de9..ddc991d 100644 --- a/src/command/handlers/memory_crud.rs +++ b/src/command/handlers/memory_crud.rs @@ -1,8 +1,8 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse}; -use crate::command::Command; -use crate::storage::{MemoryUpsert, SessionStore, GLOBAL_SCOPE_KEY}; +use crate::storage::{GLOBAL_SCOPE_KEY, MemoryUpsert, SessionStore}; use async_trait::async_trait; use std::sync::Arc; @@ -17,10 +17,7 @@ impl MemoryCrudCommandHandler { } /// 通过 ID 查找记忆的 namespace 和 memory_key -fn find_by_id( - store: &SessionStore, - id: &str, -) -> Result, CommandError> { +fn find_by_id(store: &SessionStore, id: &str) -> Result, CommandError> { let records = store .list_memories_for_scope("user", GLOBAL_SCOPE_KEY) .map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?; @@ -35,7 +32,9 @@ impl CommandHandler for MemoryCrudCommandHandler { fn can_handle(&self, cmd: &Command) -> bool { matches!( cmd, - Command::CreateMemory { .. } | Command::UpdateMemory { .. } | Command::DeleteMemory { .. } + Command::CreateMemory { .. } + | Command::UpdateMemory { .. } + | Command::DeleteMemory { .. } ) } @@ -112,7 +111,6 @@ impl CommandHandler for MemoryCrudCommandHandler { _ => unreachable!(), } - Ok(CommandResponse::success(ctx.request_id) - .with_metadata("memory_updated", "true")) + Ok(CommandResponse::success(ctx.request_id).with_metadata("memory_updated", "true")) } } diff --git a/src/command/handlers/mod.rs b/src/command/handlers/mod.rs index bd702f2..378ac75 100644 --- a/src/command/handlers/mod.rs +++ b/src/command/handlers/mod.rs @@ -4,15 +4,15 @@ pub mod help; pub mod list_channels; pub mod list_memories; pub mod list_scheduler_jobs; -pub mod list_skills; -pub mod list_todos; -pub mod memory_crud; pub mod list_sessions; pub mod list_sessions_by_channel; +pub mod list_skills; +pub mod list_todos; pub mod list_topics; pub mod load_chat_messages; pub mod load_task_messages; pub mod load_topic; +pub mod memory_crud; pub mod rename_topic; pub mod save_session; pub mod save_topic; @@ -22,8 +22,7 @@ pub mod switch_topic; // 导出公共函数供其他模块复用 pub use save_session::{ - escape_yaml_string, format_message_content, format_timestamp, - generate_messages_markdown, generate_system_prompt_markdown, - generate_subagent_tasks_markdown, load_subagent_data, SubagentTaskData, + SubagentTaskData, escape_yaml_string, format_message_content, format_timestamp, + generate_messages_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown, + load_subagent_data, }; - diff --git a/src/command/handlers/rename_topic.rs b/src/command/handlers/rename_topic.rs index 299b124..004bbd8 100644 --- a/src/command/handlers/rename_topic.rs +++ b/src/command/handlers/rename_topic.rs @@ -1,8 +1,8 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handlers::list_topics::TopicSummary; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::SessionStore; use async_trait::async_trait; use std::sync::Arc; @@ -86,7 +86,10 @@ async fn handle_rename_topic( let topic_summaries = serialize_summaries(&topics); return Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title)) + .with_message( + MessageKind::Notification, + &format!("✓ 话题标题未变化: {}", trimmed_title), + ) .with_metadata("topics", &topic_summaries) .with_metadata("topic_id", &topic_id) .with_metadata("title", trimmed_title) @@ -149,9 +152,7 @@ mod tests { let store = handler.store.clone(); let session = store.create_session("test_channel", Some("test")).unwrap(); - let topic = store - .create_topic(&session.id, "old title", None) - .unwrap(); + let topic = store.create_topic(&session.id, "old title", None).unwrap(); let ctx = CommandContext::new("test", "test_channel") .with_session_id(&session.id) @@ -166,8 +167,14 @@ mod tests { let resp = result.unwrap(); assert!(resp.success); - assert_eq!(resp.metadata.get("title").map(String::as_str), Some("new title")); - assert_eq!(resp.metadata.get("topic_id").map(String::as_str), Some(topic.id.as_str())); + assert_eq!( + resp.metadata.get("title").map(String::as_str), + Some("new title") + ); + assert_eq!( + resp.metadata.get("topic_id").map(String::as_str), + Some(topic.id.as_str()) + ); assert!(resp.metadata.contains_key("topics")); // 验证存储层已更新 @@ -181,9 +188,7 @@ mod tests { let store = handler.store.clone(); let session = store.create_session("test_channel", Some("test")).unwrap(); - let topic = store - .create_topic(&session.id, "old title", None) - .unwrap(); + let topic = store.create_topic(&session.id, "old title", None).unwrap(); let ctx = CommandContext::new("test", "test_channel") .with_session_id(&session.id) @@ -229,9 +234,7 @@ mod tests { let store = handler.store.clone(); let session = store.create_session("test_channel", Some("test")).unwrap(); - let topic = store - .create_topic(&session.id, "same title", None) - .unwrap(); + let topic = store.create_topic(&session.id, "same title", None).unwrap(); let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at; // 等待一秒确保 updated_at 会变化(如果真的写入) diff --git a/src/command/handlers/save_session.rs b/src/command/handlers/save_session.rs index 1231d75..915696a 100644 --- a/src/command/handlers/save_session.rs +++ b/src/command/handlers/save_session.rs @@ -1,12 +1,12 @@ +use crate::agent::AgentError; use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::bus::InboundMessage; +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::{SessionRecord, SessionStore}; use crate::tools::task::repository::TaskRepository; -use crate::agent::AgentError; use async_trait::async_trait; use chrono::{Local, TimeZone}; use std::path::PathBuf; @@ -65,7 +65,8 @@ pub async fn save_session_to_file( let system_prompt = build_system_prompt(system_prompt_provider, &record, user_message_count); // 生成 Markdown 内容 - let markdown = generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data); + let markdown = + generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data); // 确定输出路径 let output_path = resolve_filepath(filepath, &record); @@ -79,8 +80,7 @@ pub async fn save_session_to_file( } // 写入文件 - std::fs::write(&output_path, markdown) - .map_err(|e| format!("Failed to write file: {}", e))?; + std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?; Ok(output_path) } @@ -134,9 +134,11 @@ impl CommandHandler for SaveSessionCommandHandler { ctx: CommandContext, ) -> Result { match cmd { - Command::SaveSession { filepath, include_all, include_subagents } => { - handle_save_session(self, filepath, include_all, include_subagents, ctx).await - } + Command::SaveSession { + filepath, + include_all, + include_subagents, + } => handle_save_session(self, filepath, include_all, include_subagents, ctx).await, _ => unreachable!(), } } @@ -199,13 +201,9 @@ async fn handle_save_session( // 根据 include_all 获取消息数量 let message_count = if include_all { - handler - .store - .load_all_messages(session_id) + handler.store.load_all_messages(session_id) } else { - handler - .store - .load_messages(session_id) + handler.store.load_messages(session_id) } .map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))? .len(); @@ -215,9 +213,15 @@ async fn handle_save_session( MessageKind::Notification, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 统一转换为正斜杠以保证显示完整(跨平台兼容) - &format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")), + &format!( + "Session saved to: {}", + output_path.display().to_string().replace('\\', "/") + ), + ) + .with_metadata( + "filepath", + &output_path.display().to_string().replace('\\', "/"), ) - .with_metadata("filepath", &output_path.display().to_string().replace('\\', "/")) .with_metadata("message_count", &message_count.to_string())) } @@ -347,12 +351,18 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S output.push_str("# Subagent Tasks\n\n"); for task in subagent_data { - output.push_str(&format!("## Task: {} ({})", task.description, task.subagent_type)); + output.push_str(&format!( + "## Task: {} ({})", + task.description, task.subagent_type + )); output.push('\n'); output.push_str(&format!("**Task ID:** `{}`\n\n", task.task_id)); output.push_str(&format!("**Session ID:** `{}`\n\n", task.session_id)); output.push_str(&format!("**Status:** {}\n\n", task.state)); - output.push_str(&format!("**Created:** {}\n\n", format_timestamp(task.created_at))); + output.push_str(&format!( + "**Created:** {}\n\n", + format_timestamp(task.created_at) + )); output.push_str(&format!("**Message Count:** {}\n\n", task.messages.len())); // 子智能体消息 @@ -361,7 +371,10 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S for (idx, msg) in task.messages.iter().enumerate() { output.push_str(&format!("#### Message {}\n\n", idx + 1)); output.push_str(&format!("**Role:** {}\n\n", msg.role)); - output.push_str(&format!("**Time:** {}\n\n", format_timestamp(msg.timestamp))); + output.push_str(&format!( + "**Time:** {}\n\n", + format_timestamp(msg.timestamp) + )); if let Some(ref reasoning) = msg.reasoning_content { output.push_str("**Reasoning:**\n"); @@ -676,7 +689,12 @@ impl InChatCommandHandler for SaveSessionInChatHandler { inbound: &InboundMessage, session_manager: &crate::gateway::session::SessionManager, ) -> Result, AgentError> { - let Command::SaveSession { filepath, include_all, include_subagents } = cmd else { + let Command::SaveSession { + filepath, + include_all, + include_subagents, + } = cmd + else { return Ok(None); }; @@ -707,7 +725,10 @@ impl InChatCommandHandler for SaveSessionInChatHandler { // 返回成功或失败消息 match result { Ok(output_path) => { - let msg = format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")); + let msg = format!( + "Session saved to: {}", + output_path.display().to_string().replace('\\', "/") + ); tracing::info!("{}", msg); Ok(Some(msg)) } @@ -774,7 +795,10 @@ mod tests { fn test_escape_yaml_string() { assert_eq!(escape_yaml_string("simple"), "simple"); assert_eq!(escape_yaml_string("with: colon"), "\"with: colon\""); - assert_eq!(escape_yaml_string("with \"quote\""), "\"with \\\"quote\\\"\""); + assert_eq!( + escape_yaml_string("with \"quote\""), + "\"with \\\"quote\\\"\"" + ); } #[test] @@ -835,14 +859,26 @@ mod tests { #[test] fn test_can_handle() { let store = Arc::new(SessionStore::in_memory().unwrap()); - let task_repository = Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new()); + let task_repository = + Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new()); let provider = Arc::new(TestSystemPromptProvider); let handler = SaveSessionCommandHandler::new(store, task_repository, provider); - assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: false, include_subagents: false })); - assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: true, include_subagents: false })); + assert!(handler.can_handle(&Command::SaveSession { + filepath: None, + include_all: false, + include_subagents: false + })); + assert!(handler.can_handle(&Command::SaveSession { + filepath: None, + include_all: true, + include_subagents: false + })); assert!(!handler.can_handle(&Command::CreateSession { title: None })); - assert!(!handler.can_handle(&Command::SaveTopic { filepath: None, include_subagents: false })); + assert!(!handler.can_handle(&Command::SaveTopic { + filepath: None, + include_subagents: false + })); } /// 测试用的系统提示词提供者 diff --git a/src/command/handlers/save_topic.rs b/src/command/handlers/save_topic.rs index e70ff14..cefb9cd 100644 --- a/src/command/handlers/save_topic.rs +++ b/src/command/handlers/save_topic.rs @@ -1,14 +1,13 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::bus::ChatMessage; +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handlers::{ - escape_yaml_string, format_timestamp, generate_messages_markdown, - generate_subagent_tasks_markdown, generate_system_prompt_markdown, - load_subagent_data, SubagentTaskData, + SubagentTaskData, escape_yaml_string, format_timestamp, generate_messages_markdown, + generate_subagent_tasks_markdown, generate_system_prompt_markdown, load_subagent_data, }; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::storage::{SessionStore, TopicRecord}; use crate::tools::task::repository::TaskRepository; use async_trait::async_trait; @@ -63,8 +62,7 @@ pub async fn save_topic_to_file( } // 写入文件 - std::fs::write(&output_path, markdown) - .map_err(|e| format!("Failed to write file: {}", e))?; + std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?; Ok(output_path) } @@ -210,9 +208,10 @@ impl CommandHandler for SaveTopicCommandHandler { ctx: CommandContext, ) -> Result { match cmd { - Command::SaveTopic { filepath, include_subagents } => { - handle_save_topic(self, filepath, include_subagents, ctx).await - } + Command::SaveTopic { + filepath, + include_subagents, + } => handle_save_topic(self, filepath, include_subagents, ctx).await, _ => unreachable!(), } } @@ -249,14 +248,19 @@ async fn handle_save_topic( .store .get_topic(topic_id) .map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; + .ok_or_else(|| { + CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)) + })?; let messages = handler .store .load_messages_for_topic(topic_id, Some(&topic_record.session_id)) .map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?; - tracing::debug!(message_count = messages.len(), "Loaded messages from DB for topic"); + tracing::debug!( + message_count = messages.len(), + "Loaded messages from DB for topic" + ); // 调用保存函数 let output_path = save_topic_to_file( @@ -278,8 +282,14 @@ async fn handle_save_topic( MessageKind::Notification, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 统一转换为正斜杠以保证显示完整(跨平台兼容) - &format!("Topic saved to: {}", output_path.display().to_string().replace('\\', "/")), + &format!( + "Topic saved to: {}", + output_path.display().to_string().replace('\\', "/") + ), + ) + .with_metadata( + "filepath", + &output_path.display().to_string().replace('\\', "/"), ) - .with_metadata("filepath", &output_path.display().to_string().replace('\\', "/")) .with_metadata("message_count", &message_count.to_string())) -} \ No newline at end of file +} diff --git a/src/command/handlers/session.rs b/src/command/handlers/session.rs index fd5704f..7da9549 100644 --- a/src/command/handlers/session.rs +++ b/src/command/handlers/session.rs @@ -1,8 +1,8 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handlers::list_topics::TopicSummary; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::gateway::session::SessionManager; use crate::storage::SessionStore; use async_trait::async_trait; @@ -56,7 +56,9 @@ impl CommandHandler for SessionCommandHandler { ) -> Result { match cmd { Command::CreateSession { title } => handle_create_session(self, title, ctx).await, - Command::SaveSession { .. } => unreachable!("SaveSession should be handled by SaveSessionCommandHandler"), + Command::SaveSession { .. } => { + unreachable!("SaveSession should be handled by SaveSessionCommandHandler") + } _ => unreachable!("Other commands should be handled by other handlers"), } } @@ -69,13 +71,16 @@ async fn handle_create_session( ctx: CommandContext, ) -> Result { // 获取当前 session_id,如果没有则报错 - let session_id = ctx.session_id.as_deref() - .ok_or_else(|| CommandError::new("NO_SESSION", "No active session. Please ensure a session exists first."))?; + let session_id = ctx.session_id.as_deref().ok_or_else(|| { + CommandError::new( + "NO_SESSION", + "No active session. Please ensure a session exists first.", + ) + })?; // 创建新话题(在同一个 Session 内) - let topic_title = title.unwrap_or_else(|| { - format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8]) - }); + let topic_title = + title.unwrap_or_else(|| format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8])); let topic = handler .store @@ -83,14 +88,17 @@ async fn handle_create_session( .map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?; // 获取 chat_id - let chat_id = ctx.chat_id.as_deref() + let chat_id = ctx + .chat_id + .as_deref() .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; // 如果有 SessionManager,自动切换到新话题 if let Some(ref session_manager) = handler.session_manager { if let Some(session) = session_manager.get(&ctx.channel_name).await { let mut session_guard = session.lock().await; - session_guard.switch_topic(chat_id, &topic.id) + session_guard + .switch_topic(chat_id, &topic.id) .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; } } diff --git a/src/command/handlers/stop_execution.rs b/src/command/handlers/stop_execution.rs index fb0d06b..a191de5 100644 --- a/src/command/handlers/stop_execution.rs +++ b/src/command/handlers/stop_execution.rs @@ -1,9 +1,9 @@ use async_trait::async_trait; +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::gateway::cancel_manager::CancelManager; use crate::gateway::session::SessionManager; @@ -15,7 +15,10 @@ pub struct StopExecutionCommandHandler { impl StopExecutionCommandHandler { pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self { - Self { cancel_manager, session_manager } + Self { + cancel_manager, + session_manager, + } } } @@ -53,7 +56,11 @@ impl CommandHandler for StopExecutionCommandHandler { None => { // 从 SessionManager 获取真实的 current topic let chat_id = ctx.chat_id.as_deref().unwrap_or(""); - match self.session_manager.get_current_topic(&ctx.channel_name, chat_id).await { + match self + .session_manager + .get_current_topic(&ctx.channel_name, chat_id) + .await + { Ok(Some(id)) => { tracing::info!( channel = %ctx.channel_name, @@ -65,12 +72,16 @@ impl CommandHandler for StopExecutionCommandHandler { id } Ok(None) => { - return Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止")); + return Ok(CommandResponse::success(ctx.request_id).with_message( + MessageKind::Notification, + "当前没有活跃的话题,无法停止", + )); } Err(e) => { - return Ok(CommandResponse::error(ctx.request_id, - CommandError::new("QUERY_TOPIC_ERROR", e.to_string()))); + return Ok(CommandResponse::error( + ctx.request_id, + CommandError::new("QUERY_TOPIC_ERROR", e.to_string()), + )); } } } diff --git a/src/command/handlers/switch_topic.rs b/src/command/handlers/switch_topic.rs index 7c22146..959b892 100644 --- a/src/command/handlers/switch_topic.rs +++ b/src/command/handlers/switch_topic.rs @@ -1,7 +1,7 @@ +use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; use crate::gateway::session::SessionManager; use crate::storage::SessionStore; use async_trait::async_trait; @@ -15,7 +15,10 @@ pub struct SwitchTopicCommandHandler { impl SwitchTopicCommandHandler { pub fn new(store: Arc) -> Self { - Self { store, session_manager: None } + Self { + store, + session_manager: None, + } } pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self { @@ -44,9 +47,7 @@ impl CommandHandler for SwitchTopicCommandHandler { ctx: CommandContext, ) -> Result { match cmd { - Command::SwitchTopic { topic_id } => { - handle_switch_topic(self, topic_id, ctx).await - } + Command::SwitchTopic { topic_id } => handle_switch_topic(self, topic_id, ctx).await, _ => unreachable!(), } } @@ -57,9 +58,13 @@ async fn handle_switch_topic( topic_id: String, ctx: CommandContext, ) -> Result { - let session_id = ctx.session_id.as_deref() + let session_id = ctx + .session_id + .as_deref() .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; - let chat_id = ctx.chat_id.as_deref() + let chat_id = ctx + .chat_id + .as_deref() .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; // 尝试解析为序号 @@ -73,7 +78,11 @@ async fn handle_switch_topic( if index >= topics.len() { return Err(CommandError::new( "INVALID_TOPIC_INDEX", - format!("Topic index {} is out of range (1-{})", index + 1, topics.len()) + format!( + "Topic index {} is out of range (1-{})", + index + 1, + topics.len() + ), )); } topics[index].id.clone() @@ -86,19 +95,26 @@ async fn handle_switch_topic( .store .get_topic(&target_topic_id) .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", target_topic_id)))?; + .ok_or_else(|| { + CommandError::new( + "TOPIC_NOT_FOUND", + format!("Topic not found: {}", target_topic_id), + ) + })?; // 如果有 SessionManager,实际切换话题历史 if let Some(ref session_manager) = handler.session_manager { if let Some(session) = session_manager.get(&ctx.channel_name).await { let mut session_guard = session.lock().await; - session_guard.switch_topic(chat_id, &target_topic_id) + session_guard + .switch_topic(chat_id, &target_topic_id) .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; } } // 使用辅助方法获取消息数量 - let msg_count = handler.store + let msg_count = handler + .store .get_topic_message_count(&target_topic_id) .unwrap_or(0); diff --git a/src/command/mod.rs b/src/command/mod.rs index 35d6c7f..6861a10 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -48,10 +48,7 @@ pub enum Command { /// 列出所有定时任务 ListSchedulerJobs, /// 加载指定 channel + chat_id 的对话消息 - LoadChatMessages { - channel: String, - chat_id: String, - }, + LoadChatMessages { channel: String, chat_id: String }, /// 删除指定话题 DeleteTopic { topic_id: String }, /// 重命名指定话题 @@ -67,10 +64,7 @@ pub enum Command { content: String, }, /// 更新已有记忆 - UpdateMemory { - id: String, - content: String, - }, + UpdateMemory { id: String, content: String }, /// 删除记忆 DeleteMemory { id: String }, /// 列出所有技能 diff --git a/src/config/mod.rs b/src/config/mod.rs index 4275d44..a39dfff 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -261,7 +261,7 @@ fn default_task_enabled() -> bool { } fn default_task_max_execution_secs() -> u64 { - 3600 // 60分钟 + 3600 // 60分钟 } fn default_task_ttl_hours() -> u64 { @@ -1061,7 +1061,10 @@ pub struct ModelResolver { } impl ModelResolver { - pub fn new(providers: HashMap, models: HashMap) -> Self { + pub fn new( + providers: HashMap, + models: HashMap, + ) -> Self { Self { providers, models } } @@ -1156,11 +1159,12 @@ fn resolve_env_placeholders(content: &str) -> String { env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) }); - re_angle.replace_all(&content, |caps: ®ex::Captures| { - let var_name = &caps[1]; - env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) - }) - .to_string() + re_angle + .replace_all(&content, |caps: ®ex::Captures| { + let var_name = &caps[1]; + env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) + }) + .to_string() } #[cfg(test)] @@ -1738,7 +1742,8 @@ mod tests { "allow_from": ["wxid_1"] } } -}"#.replace("", &cred_path_json), +}"# + .replace("", &cred_path_json), ) .unwrap(); diff --git a/src/experts/mod.rs b/src/experts/mod.rs index 19d5915..e538122 100644 --- a/src/experts/mod.rs +++ b/src/experts/mod.rs @@ -12,7 +12,9 @@ static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[cfg(test)] pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> { - EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) + EXPERT_TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()) } /// A discovered expert definition. @@ -291,13 +293,20 @@ impl ExpertRuntime { /// Re-discover experts from the filesystem. pub fn reload(&self) -> Result { - let config = self.config.read().expect("experts config rwlock poisoned").clone(); + let config = self + .config + .read() + .expect("experts config rwlock poisoned") + .clone(); let catalog = ExpertCatalog::discover_with_state( &config, &self.cwd, Some(&load_expert_disable_state(&self.cwd)), ); - let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned"); + let mut guard = self + .catalog + .write() + .expect("experts catalog rwlock poisoned"); *guard = catalog.clone(); Ok(catalog) } @@ -324,7 +333,11 @@ impl ExpertRuntime { /// List all discovered experts including disabled ones, with their disabled scopes. pub fn list_experts_with_status(&self) -> Vec { - let config = self.config.read().expect("experts config rwlock poisoned").clone(); + let config = self + .config + .read() + .expect("experts config rwlock poisoned") + .clone(); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); let disable_state = load_expert_disable_state(&self.cwd); @@ -411,7 +424,15 @@ impl ExpertRuntime { let next_provider = provider.cloned().unwrap_or(existing.provider); let next_model = model.cloned().unwrap_or(existing.model); - write_expert_file(&path, name, next_description, next_body, &next_capability, &next_provider, &next_model)?; + write_expert_file( + &path, + name, + next_description, + next_body, + &next_capability, + &next_provider, + &next_model, + )?; let expert = parse_expert_file(&path, scope.into())?; if reload { let _ = self.reload()?; @@ -457,7 +478,11 @@ impl ExpertRuntime { pub fn has_expert_definition(&self, name: &str) -> Result { validate_expert_name(name)?; - let config = self.config.read().expect("experts config rwlock poisoned").clone(); + let config = self + .config + .read() + .expect("experts config rwlock poisoned") + .clone(); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); Ok(catalog.find_expert(name).is_some()) } @@ -536,10 +561,7 @@ impl ExpertRuntime { } // The expert must exist (and not be disabled) for selection to be meaningful. if self.get_expert(expert_name).is_none() { - return Err(format!( - "expert '{}' not found or disabled", - expert_name - )); + return Err(format!("expert '{}' not found or disabled", expert_name)); } { @@ -624,10 +646,7 @@ impl SystemPromptProvider for ExpertPromptProvider { let content = if expert.body.trim().is_empty() { // Empty body is OK; inject a header so the LLM still knows the role. - format!( - "# 专家角色: {}\n\n{}", - expert.name, expert.description - ) + format!("# 专家角色: {}\n\n{}", expert.name, expert.description) } else { expert.body.clone() }; @@ -667,8 +686,9 @@ fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf { fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result { match scope { - ExpertScope::User => user_experts_root() - .ok_or_else(|| "failed to resolve home directory".to_string()), + ExpertScope::User => { + user_experts_root().ok_or_else(|| "failed to resolve home directory".to_string()) + } ExpertScope::Project => Ok(project_experts_root(cwd)), } } @@ -1020,7 +1040,10 @@ fn load_project_session_experts(cwd: &Path) -> HashMap { /// Persist a mutation to the project-scope state file's session_experts while /// preserving the existing disabled_experts field. -fn persist_session_experts(cwd: &Path, mutate: F) -> Result<(), String> { +fn persist_session_experts( + cwd: &Path, + mutate: F, +) -> Result<(), String> { let path = project_expert_state_path(cwd); let mut state = load_expert_state_file(&path)?; mutate(&mut state); @@ -1097,7 +1120,11 @@ mod tests { let expert_dir = dir.path().join("demo"); fs::create_dir_all(&expert_dir).unwrap(); let expert_md = expert_dir.join("EXPERT.md"); - fs::write(&expert_md, "---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B").unwrap(); + fs::write( + &expert_md, + "---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B", + ) + .unwrap(); let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap(); assert_eq!(expert.name, "demo"); @@ -1137,33 +1164,49 @@ mod tests { #[test] fn test_render_expert_file_requires_description() { - let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default(), &None, &None).unwrap_err(); + let err = render_expert_file( + "demo", + " ", + "body", + &CapabilityPolicy::default(), + &None, + &None, + ) + .unwrap_err(); assert!(err.contains("description")); } #[test] fn test_capability_policy_is_empty_helpers() { assert!(CapabilityPolicy::default().is_empty()); - assert!(!CapabilityPolicy { - allowed_skills: Some(vec!["a".to_string()]), - ..Default::default() - } - .is_empty()); - assert!(CapabilityPolicy { - denied_tools: vec![], - ..Default::default() - } - .is_empty()); - assert!(CapabilityPolicy { - allowed_tools: Some(vec![]), - ..Default::default() - } - .has_tool_policy()); - assert!(CapabilityPolicy { - denied_skills: vec!["x".to_string()], - ..Default::default() - } - .has_skill_policy()); + assert!( + !CapabilityPolicy { + allowed_skills: Some(vec!["a".to_string()]), + ..Default::default() + } + .is_empty() + ); + assert!( + CapabilityPolicy { + denied_tools: vec![], + ..Default::default() + } + .is_empty() + ); + assert!( + CapabilityPolicy { + allowed_tools: Some(vec![]), + ..Default::default() + } + .has_tool_policy() + ); + assert!( + CapabilityPolicy { + denied_skills: vec!["x".to_string()], + ..Default::default() + } + .has_skill_policy() + ); } #[test] @@ -1196,7 +1239,15 @@ mod tests { #[test] fn test_empty_capability_omits_keys() { // 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容 - let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default(), &None, &None).unwrap(); + let rendered = render_expert_file( + "plain", + "desc", + "body", + &CapabilityPolicy::default(), + &None, + &None, + ) + .unwrap(); assert!(!rendered.contains("allowed_skills")); assert!(!rendered.contains("denied_skills")); assert!(!rendered.contains("allowed_tools")); @@ -1224,10 +1275,7 @@ mod tests { .unwrap(); // project scope (overrides user) - let project_dir_expert = project_dir - .join(".picobot") - .join("experts") - .join("demo"); + let project_dir_expert = project_dir.join(".picobot").join("experts").join("demo"); fs::create_dir_all(&project_dir_expert).unwrap(); fs::write( project_dir_expert.join("EXPERT.md"), @@ -1310,7 +1358,16 @@ mod tests { // update with None preserves fields let updated_none = runtime - .update_expert(ExpertScope::Project, "translator", None, None, None, None, None, true) + .update_expert( + ExpertScope::Project, + "translator", + None, + None, + None, + None, + None, + true, + ) .unwrap(); assert_eq!(updated_none.description, "更新翻译专家"); assert_eq!(updated_none.body, "你是一名中文教师。"); @@ -1509,13 +1566,21 @@ mod tests { .unwrap(); let items = runtime.list_experts_with_status(); - assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts"); + assert_eq!( + items.len(), + 1, + "list_experts_with_status should include disabled experts" + ); assert_eq!(items[0].name, "planner"); assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]); // list_experts (filtered) should be empty let active = runtime.list_experts(); - assert_eq!(active.len(), 0, "list_experts should filter out disabled experts"); + assert_eq!( + active.len(), + 0, + "list_experts should filter out disabled experts" + ); } #[test] @@ -1526,7 +1591,9 @@ mod tests { session_experts: HashMap::new(), disabled_experts: vec!["demo".to_string()], }; - state.session_experts.insert("sess-1".to_string(), "demo".to_string()); + state + .session_experts + .insert("sess-1".to_string(), "demo".to_string()); save_expert_state_file(&path, &state).unwrap(); diff --git a/src/frontmatter.rs b/src/frontmatter.rs index 763b8f6..f803451 100644 --- a/src/frontmatter.rs +++ b/src/frontmatter.rs @@ -1,5 +1,5 @@ -use gray_matter::engine::YAML; use gray_matter::Matter; +use gray_matter::engine::YAML; use serde::de::DeserializeOwned; /// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`. @@ -45,7 +45,13 @@ mod tests { fn parses_lf_endings() { let input = "---\ndescription: demo\n---\nbody text"; let (fm, body) = parse::(input).unwrap(); - assert_eq!(fm, FrontMatter { description: "demo".to_string(), name: None }); + assert_eq!( + fm, + FrontMatter { + description: "demo".to_string(), + name: None + } + ); assert_eq!(body, "body text"); } diff --git a/src/gateway/agent_factory.rs b/src/gateway/agent_factory.rs index 931adb1..11b3e30 100644 --- a/src/gateway/agent_factory.rs +++ b/src/gateway/agent_factory.rs @@ -9,8 +9,8 @@ use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::model_selection::ModelSelectionStore; use crate::gateway::tool_prompt_provider::ToolPromptProvider; use crate::skills::{SkillPromptProvider, SkillRuntime}; -use crate::storage::persistent_session_id; use crate::storage::PromptInjectionRepository; +use crate::storage::persistent_session_id; use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; use crate::tools::{ToolContext, ToolRegistry}; @@ -112,12 +112,14 @@ impl AgentFactory { // 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。 let expert_provider_config = match &expert { Some(e) if e.provider.is_some() || e.model.is_some() => { - let resolved = self.model_resolver.resolve( - e.provider.as_deref(), - e.model.as_deref(), - &request.provider_config, - ) - .map_err(|e| AgentError::Other(e.to_string()))?; + let resolved = self + .model_resolver + .resolve( + e.provider.as_deref(), + e.model.as_deref(), + &request.provider_config, + ) + .map_err(|e| AgentError::Other(e.to_string()))?; tracing::info!( instance_id = self.instance_id, session_id = %session_id, @@ -133,30 +135,29 @@ impl AgentFactory { // 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。 // 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。 - let effective_provider_config = - match self.model_selections.get(&session_id) { - Some((user_provider, user_model)) - if user_provider.is_some() || user_model.is_some() => - { - let resolved = self - .model_resolver - .resolve( - user_provider.as_deref(), - user_model.as_deref(), - &expert_provider_config, - ) - .map_err(|e| AgentError::Other(e.to_string()))?; - tracing::info!( - instance_id = self.instance_id, - session_id = %session_id, - provider = %resolved.name, - model_id = %resolved.model_id, - "AgentFactory: applied user model override" - ); - resolved - } - _ => expert_provider_config, - }; + let effective_provider_config = match self.model_selections.get(&session_id) { + Some((user_provider, user_model)) + if user_provider.is_some() || user_model.is_some() => + { + let resolved = self + .model_resolver + .resolve( + user_provider.as_deref(), + user_model.as_deref(), + &expert_provider_config, + ) + .map_err(|e| AgentError::Other(e.to_string()))?; + tracing::info!( + instance_id = self.instance_id, + session_id = %session_id, + provider = %resolved.name, + model_id = %resolved.model_id, + "AgentFactory: applied user model override" + ); + resolved + } + _ => expert_provider_config, + }; // 诊断日志:记录 agent 实际使用的配置和实例 ID tracing::info!( diff --git a/src/gateway/agent_task_executor.rs b/src/gateway/agent_task_executor.rs index fb23b1f..fb1da9d 100644 --- a/src/gateway/agent_task_executor.rs +++ b/src/gateway/agent_task_executor.rs @@ -40,7 +40,13 @@ impl AgentTaskExecutor { options: ScheduledAgentTaskOptions, ) -> Result, AgentError> { self.session_manager - .run_silent_agent_task(channel_name, session_chat_id, notification_chat_id, prompt, options) + .run_silent_agent_task( + channel_name, + session_chat_id, + notification_chat_id, + prompt, + options, + ) .await } } @@ -93,8 +99,12 @@ impl SchedulerMaintenanceService { self.session_manager.cleanup_expired_sessions().await } - async fn run_memory_maintenance(&self) -> Result, AgentError> { - self.session_manager.run_memory_maintenance_for_all_scopes().await + async fn run_memory_maintenance( + &self, + ) -> Result, AgentError> { + self.session_manager + .run_memory_maintenance_for_all_scopes() + .await } } @@ -104,7 +114,9 @@ impl MaintenanceExecutor for SchedulerMaintenanceService { self.cleanup_sessions().await } - async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result> { + async fn run_memory_maintenance_for_all_scopes( + &self, + ) -> anyhow::Result> { self.run_memory_maintenance() .await .map(|results| { diff --git a/src/gateway/execution.rs b/src/gateway/execution.rs index 2a61c0b..f48d420 100644 --- a/src/gateway/execution.rs +++ b/src/gateway/execution.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; use std::sync::Arc; -use async_trait::async_trait; -use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPromptContext}; +use crate::agent::{ + AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, + SystemPromptContext, +}; use crate::bus::message::ToolMessageState; use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT}; use crate::config::LLMProviderConfig; -use crate::storage::{persistent_session_id, ConversationRepository}; +use crate::storage::{ConversationRepository, persistent_session_id}; +use async_trait::async_trait; use tokio::sync::Mutex; use super::compaction::schedule_background_history_compaction; @@ -100,9 +103,12 @@ impl AgentExecutionService { }; if !is_current_turn { - let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = - session.stale_result_diagnostics( - request.original_topic_id.as_deref().unwrap_or(request.chat_id), + let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session + .stale_result_diagnostics( + request + .original_topic_id + .as_deref() + .unwrap_or(request.chat_id), ); tracing::info!( channel = %request.channel_name, @@ -126,10 +132,9 @@ impl AgentExecutionService { if let Some(topic_id) = target_topic_id { if is_current_turn { // 话题未切换(current_topic == original_topic_id),安全更新内存历史 - if let Err(err) = session.append_persisted_messages( - topic_id, - request.result.emitted_messages.clone(), - ) { + if let Err(err) = session + .append_persisted_messages(topic_id, request.result.emitted_messages.clone()) + { tracing::error!( error = %err, topic_id = %topic_id, @@ -153,10 +158,9 @@ impl AgentExecutionService { } else if is_current_turn { // 没有话题:直接更新内存历史(append_persisted_messages 会处理持久化) // 无 topic 场景用 chat_id 作为 topic_histories 的回退 key - if let Err(err) = session.append_persisted_messages( - request.chat_id, - request.result.emitted_messages.clone(), - ) { + if let Err(err) = session + .append_persisted_messages(request.chat_id, request.result.emitted_messages.clone()) + { tracing::error!( error = %err, chat_id = %request.chat_id, @@ -274,7 +278,13 @@ impl AgentExecutionService { agent = agent.with_emitted_message_handler(handler); } - (history, agent, user_message, user_message_count, original_topic_id) + ( + history, + agent, + user_message, + user_message_count, + original_topic_id, + ) }; // 构建系统提示词上下文 @@ -324,7 +334,15 @@ impl AgentExecutionService { // 等待该 topic 的前一条消息处理完成(含压缩) let _serial_guard = serial_lock.lock().await; - let (history, mut agent, user_message, user_message_count, original_topic_id, store, session_id) = { + let ( + history, + mut agent, + user_message, + user_message_count, + original_topic_id, + store, + session_id, + ) = { let mut session_guard = request.session.lock().await; session_guard.ensure_persistent_session(request.chat_id)?; @@ -382,12 +400,18 @@ impl AgentExecutionService { // 获取 store 和 session_id,用于构造消息持久化 handler let store = session_guard.store(); - let session_id = crate::storage::persistent_session_id( - request.channel_name, - request.chat_id, - ); + let session_id = + crate::storage::persistent_session_id(request.channel_name, request.chat_id); - (history, agent, user_message, user_message_count, original_topic_id, store, session_id) + ( + history, + agent, + user_message, + user_message_count, + original_topic_id, + store, + session_id, + ) }; // 定时任务没有 live_emitter,需要 PersistingEmittedMessageHandler 来持久化消息 @@ -410,20 +434,21 @@ impl AgentExecutionService { let result = agent.process(history, Some(&system_prompt_context)).await?; - let outbound_messages = self.finalize_result_and_schedule_compaction( - request.session.clone(), - FinalizeAgentResultRequest { - channel_name: request.channel_name, - chat_id: request.chat_id, - user_message: &user_message, - result, - metadata: request.metadata, - suppress_live_tool_calls: false, - execution_kind: "scheduled_task", - original_topic_id: original_topic_id.clone(), - }, - ) - .await?; + let outbound_messages = self + .finalize_result_and_schedule_compaction( + request.session.clone(), + FinalizeAgentResultRequest { + channel_name: request.channel_name, + chat_id: request.chat_id, + user_message: &user_message, + result, + metadata: request.metadata, + suppress_live_tool_calls: false, + execution_kind: "scheduled_task", + original_topic_id: original_topic_id.clone(), + }, + ) + .await?; // 清理内存历史,释放内存(数据库历史保留) { @@ -543,11 +568,7 @@ mod tests { let _guard1 = lock.lock().await; // 第二次获取应阻塞,1ms 超时验证 - let result = tokio::time::timeout( - std::time::Duration::from_millis(1), - lock.lock(), - ) - .await; + let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await; assert!(result.is_err(), "第二次获取同一锁应阻塞"); } @@ -561,11 +582,8 @@ mod tests { let _guard_a = lock_a.lock().await; // 不同锁应立即可获取 - let result = tokio::time::timeout( - std::time::Duration::from_millis(100), - lock_b.lock(), - ) - .await; + let result = + tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await; assert!(result.is_ok(), "不同 topic 的锁应互不影响"); } @@ -585,11 +603,7 @@ mod tests { } // 锁应已释放,可再次获取 - let result = tokio::time::timeout( - std::time::Duration::from_millis(100), - lock.lock(), - ) - .await; + let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await; assert!(result.is_ok(), "错误返回后锁应已释放"); } diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 868b9fc..a5ce87e 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -1,5 +1,8 @@ -use axum::{Json, extract::{Query, State}}; use axum::http::StatusCode; +use axum::{ + Json, + extract::{Query, State}, +}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -90,9 +93,7 @@ pub struct SaveConfigResponse { } /// GET /api/config — Return current config with masked sensitive fields -pub async fn get_config( - State(state): State>, -) -> Json { +pub async fn get_config(State(state): State>) -> Json { Json(mask_config(&*state.config.read().await)) } @@ -138,11 +139,19 @@ pub async fn save_config( .unwrap_or_else(|_| get_default_config_path()); // Serialize and write to disk (no lock held) - let json = serde_json::to_string_pretty(&new_config) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?; + let json = serde_json::to_string_pretty(&new_config).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Serialize error: {}", e), + ) + })?; - std::fs::write(&config_path, &json) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?; + std::fs::write(&config_path, &json).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Write error: {}", e), + ) + })?; // Update in-memory config (write lock, held only for assignment) { @@ -226,9 +235,7 @@ pub async fn mcp_status( } /// GET /api/skills — Return all discovered skills with their disabled status -pub async fn skills_list( - State(state): State>, -) -> Json { +pub async fn skills_list(State(state): State>) -> Json { let skills_enabled = state.config.read().await.skills.enabled; if !skills_enabled { @@ -281,9 +288,7 @@ pub struct CurrentModel { /// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source. /// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。 -pub async fn tools_list( - State(state): State>, -) -> Json { +pub async fn tools_list(State(state): State>) -> Json { let registry = state.session_manager.tools(); let tools: Vec = registry .get_definitions() @@ -312,9 +317,7 @@ pub async fn tools_list( } /// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。 -pub async fn model_options( - State(state): State>, -) -> Json { +pub async fn model_options(State(state): State>) -> Json { let config = state.config.read().await; let resolver = crate::config::ModelResolver::from_config(&config); // 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key) @@ -371,7 +374,11 @@ pub async fn skills_toggle( changed: Some(change.changed), available: Some(change.available), disabled_in_scopes: Some( - change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(), + change + .disabled_in_scopes + .iter() + .map(|s| s.as_str().to_string()) + .collect(), ), error: None, }), @@ -424,9 +431,7 @@ pub struct SubagentListResponse { } /// GET /api/subagents — Return all discovered subagents with their disabled status -pub async fn subagents_list( - State(state): State>, -) -> Json { +pub async fn subagents_list(State(state): State>) -> Json { let subagents_enabled = state.config.read().await.subagents.enabled; if !subagents_enabled { @@ -725,9 +730,7 @@ pub struct ExpertDeleteResponse { } /// GET /api/experts — Return all discovered experts with their disabled status -pub async fn experts_list( - State(state): State>, -) -> Json { +pub async fn experts_list(State(state): State>) -> Json { let experts_enabled = state.config.read().await.experts.enabled; if !experts_enabled { @@ -817,8 +820,12 @@ pub async fn experts_create( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { - let scope = ExpertScope::parse(&req.scope) - .ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?; + let scope = ExpertScope::parse(&req.scope).ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + format!("invalid scope: {}", req.scope), + ) + })?; let expert = state .experts @@ -849,8 +856,12 @@ pub async fn experts_update( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { - let scope = ExpertScope::parse(&req.scope) - .ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?; + let scope = ExpertScope::parse(&req.scope).ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + format!("invalid scope: {}", req.scope), + ) + })?; let expert = state .experts @@ -1011,9 +1022,7 @@ pub async fn session_select_model( } drop(config); - state - .model_selections - .set(&req.session_id, provider, model); + state.model_selections.set(&req.session_id, provider, model); ( StatusCode::OK, Json(SelectModelResponse { diff --git a/src/gateway/memory_maintenance.rs b/src/gateway/memory_maintenance.rs index 2a275c1..ba828a8 100644 --- a/src/gateway/memory_maintenance.rs +++ b/src/gateway/memory_maintenance.rs @@ -26,7 +26,7 @@ pub(crate) struct MemoryMaintenanceCandidate { pub(crate) namespace: String, pub(crate) key: String, pub(crate) content: String, - pub(crate) updated_at: i64, // 记忆更新时间(Unix timestamp) + pub(crate) updated_at: i64, // 记忆更新时间(Unix timestamp) } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -227,8 +227,8 @@ impl MemoryMaintenanceService { Ok(parsed) => return Ok(parsed), Err(err) => { let error_msg = err.to_string(); - let is_truncated = error_msg.contains("EOF while parsing") - || error_msg.contains("expected"); + let is_truncated = + error_msg.contains("EOF while parsing") || error_msg.contains("expected"); let should_retry = delay_ms.is_some() && is_truncated; last_error = Some(error_msg.clone()); @@ -369,9 +369,10 @@ impl MemoryMaintenanceService { pub(crate) async fn run_for_all_scopes( &self, ) -> Result, AgentError> { - let scope_keys = self.store.list_memory_scope_keys("user").map_err(|err| { - AgentError::Other(format!("list memory scope keys error: {}", err)) - })?; + let scope_keys = self + .store + .list_memory_scope_keys("user") + .map_err(|err| AgentError::Other(format!("list memory scope keys error: {}", err)))?; if scope_keys.is_empty() { return Ok(None); @@ -418,7 +419,8 @@ impl MemoryMaintenanceService { let managed_markdown = if all_remaining_memories.is_empty() { String::new() } else { - self.generate_summary("all", &all_remaining_memories).await? + self.generate_summary("all", &all_remaining_memories) + .await? }; if !managed_markdown.is_empty() { @@ -678,24 +680,29 @@ pub(crate) fn validate_memory_maintenance_output( } // 验证 2: 跨 namespace 合并检测(完全禁止) - let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> = plan - .candidates - .iter() - .map(|c| (c.id.as_str(), c)) - .collect(); + let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> = + plan.candidates.iter().map(|c| (c.id.as_str(), c)).collect(); for merge in &output.merges { let source_namespaces: HashSet<&str> = merge .source_ids .iter() - .filter_map(|id| candidates_by_id.get(id.as_str()).map(|c| c.namespace.as_str())) + .filter_map(|id| { + candidates_by_id + .get(id.as_str()) + .map(|c| c.namespace.as_str()) + }) .collect(); // 检查是否跨越多个 namespace if source_namespaces.len() > 1 { return Err(format!( "跨 namespace 合并被禁止: 源来自 {}", - source_namespaces.iter().cloned().collect::>().join(", ") + source_namespaces + .iter() + .cloned() + .collect::>() + .join(", ") )); } @@ -718,11 +725,7 @@ pub(crate) fn validate_memory_maintenance_output( .map(|s| s.as_str()) .collect(); - let deleted_ids: HashSet<&str> = output - .low_value_ids - .iter() - .map(|s| s.as_str()) - .collect(); + let deleted_ids: HashSet<&str> = output.low_value_ids.iter().map(|s| s.as_str()).collect(); let affected = merged_ids.len() + deleted_ids.len(); let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize; @@ -758,8 +761,14 @@ pub(crate) fn apply_memory_maintenance_output( max_merge_per_group: usize, ) -> Result<(), AgentError> { // 新增: 验证合并输出 - validate_memory_maintenance_output(plan, output, max_merge_ratio, min_memories_to_keep, max_merge_per_group) - .map_err(|e| AgentError::Other(e))?; + validate_memory_maintenance_output( + plan, + output, + max_merge_ratio, + min_memories_to_keep, + max_merge_per_group, + ) + .map_err(|e| AgentError::Other(e))?; let all_candidates = plan.candidates.clone(); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 8d5a178..f3d3973 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -25,8 +25,8 @@ pub mod session_message_sender; pub mod session_message_service; pub mod session_pool; pub mod static_files; -pub mod tool_registry_factory; pub mod tool_prompt_provider; +pub mod tool_registry_factory; pub mod ws; use axum::{Router, routing}; @@ -50,11 +50,11 @@ use cancel_manager::CancelManager; use outbound_dispatcher::OutboundDispatcher; use processor::InboundProcessor; use runtime::build_session_manager_with_sender; -use session_message_sender::BusSessionMessageSender; use session::SessionManager; +use session_message_sender::BusSessionMessageSender; use static_files::static_handler; -use tokio::sync::{watch, RwLock}; +use tokio::sync::{RwLock, watch}; pub struct GatewayState { pub config: Arc>, @@ -73,7 +73,10 @@ pub struct GatewayState { } impl GatewayState { - pub fn from_config(config: Config, restart_tx: watch::Sender) -> Result> { + pub fn from_config( + config: Config, + restart_tx: watch::Sender, + ) -> Result> { // Get provider config for SessionManager let provider_config = config.get_provider_config("default")?; let mut provider_configs = HashMap::::new(); @@ -87,7 +90,9 @@ impl GatewayState { let session_ttl_hours = config.gateway.session_ttl_hours; let skills = Arc::new(SkillRuntime::from_config(config.skills.clone())); - let experts = Arc::new(crate::experts::ExpertRuntime::from_config(config.experts.clone())); + let experts = Arc::new(crate::experts::ExpertRuntime::from_config( + config.experts.clone(), + )); let channel_manager = ChannelManager::new(); let bus = channel_manager.bus(); @@ -95,24 +100,25 @@ impl GatewayState { mcp_servers: config.mcp_servers.clone(), }; - let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender( - agent_prompt_reinject_every, - show_tool_results, - config.time.timezone.clone(), - provider_config, - provider_configs, - skills.clone(), - experts.clone(), - Arc::new(BusSessionMessageSender::new(bus.clone())), - std::collections::HashSet::new(), - config.tools.task.clone(), - config.subagents.clone(), - config.memory_maintenance.clone(), - session_ttl_hours, - mcp_config, - Some(bus.clone()), - Arc::new(crate::config::ModelResolver::from_config(&config)), - )?; + let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = + build_session_manager_with_sender( + agent_prompt_reinject_every, + show_tool_results, + config.time.timezone.clone(), + provider_config, + provider_configs, + skills.clone(), + experts.clone(), + Arc::new(BusSessionMessageSender::new(bus.clone())), + std::collections::HashSet::new(), + config.tools.task.clone(), + config.subagents.clone(), + config.memory_maintenance.clone(), + session_ttl_hours, + mcp_config, + Some(bus.clone()), + Arc::new(crate::config::ModelResolver::from_config(&config)), + )?; // 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态) tracing::info!( @@ -155,8 +161,13 @@ impl GatewayState { drop(cfg); // release read lock before spawning long-running tasks let semaphore = Arc::new(Semaphore::new(max_concurrent)); - let inbound_processor = - InboundProcessor::new(self.bus.clone(), self.session_manager.clone(), semaphore, provider_config, self.cancel_manager.clone()); + let inbound_processor = InboundProcessor::new( + self.bus.clone(), + self.session_manager.clone(), + semaphore, + provider_config, + self.cancel_manager.clone(), + ); tokio::spawn(inbound_processor.run()); // Spawn outbound dispatcher @@ -241,7 +252,10 @@ pub async fn run( let app = if use_embedded { Router::new() .route("/health", routing::get(http::health)) - .route("/api/config", routing::get(http::get_config).put(http::save_config)) + .route( + "/api/config", + routing::get(http::get_config).put(http::save_config), + ) .route("/api/restart", routing::post(http::restart)) .route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/skills", routing::get(http::skills_list)) @@ -249,17 +263,32 @@ pub async fn run( .route("/api/tools", routing::get(http::tools_list)) .route("/api/model-options", routing::get(http::model_options)) .route("/api/subagents", routing::get(http::subagents_list)) - .route("/api/subagents/toggle", routing::post(http::subagents_toggle)) - .route("/api/subagents/update", routing::put(http::subagents_update)) + .route( + "/api/subagents/toggle", + routing::post(http::subagents_toggle), + ) + .route( + "/api/subagents/update", + routing::put(http::subagents_update), + ) .route("/api/experts", routing::get(http::experts_list)) .route("/api/experts/toggle", routing::post(http::experts_toggle)) .route("/api/experts/create", routing::post(http::experts_create)) .route("/api/experts/update", routing::put(http::experts_update)) .route("/api/experts/delete", routing::delete(http::experts_delete)) - .route("/api/experts/selected", routing::get(http::experts_selected)) + .route( + "/api/experts/selected", + routing::get(http::experts_selected), + ) .route("/api/experts/select", routing::post(http::experts_select)) - .route("/api/session/select-model", routing::post(http::session_select_model)) - .route("/api/session/selected-model", routing::get(http::session_selected_model)) + .route( + "/api/session/select-model", + routing::post(http::session_select_model), + ) + .route( + "/api/session/selected-model", + routing::get(http::session_selected_model), + ) .route("/ws", routing::get(ws::ws_handler)) .fallback(static_handler) .with_state(state.clone()) @@ -267,7 +296,10 @@ pub async fn run( let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string()); Router::new() .route("/health", routing::get(http::health)) - .route("/api/config", routing::get(http::get_config).put(http::save_config)) + .route( + "/api/config", + routing::get(http::get_config).put(http::save_config), + ) .route("/api/restart", routing::post(http::restart)) .route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/skills", routing::get(http::skills_list)) @@ -275,17 +307,32 @@ pub async fn run( .route("/api/tools", routing::get(http::tools_list)) .route("/api/model-options", routing::get(http::model_options)) .route("/api/subagents", routing::get(http::subagents_list)) - .route("/api/subagents/toggle", routing::post(http::subagents_toggle)) - .route("/api/subagents/update", routing::put(http::subagents_update)) + .route( + "/api/subagents/toggle", + routing::post(http::subagents_toggle), + ) + .route( + "/api/subagents/update", + routing::put(http::subagents_update), + ) .route("/api/experts", routing::get(http::experts_list)) .route("/api/experts/toggle", routing::post(http::experts_toggle)) .route("/api/experts/create", routing::post(http::experts_create)) .route("/api/experts/update", routing::put(http::experts_update)) .route("/api/experts/delete", routing::delete(http::experts_delete)) - .route("/api/experts/selected", routing::get(http::experts_selected)) + .route( + "/api/experts/selected", + routing::get(http::experts_selected), + ) .route("/api/experts/select", routing::post(http::experts_select)) - .route("/api/session/select-model", routing::post(http::session_select_model)) - .route("/api/session/selected-model", routing::get(http::session_selected_model)) + .route( + "/api/session/select-model", + routing::post(http::session_select_model), + ) + .route( + "/api/session/selected-model", + routing::get(http::session_selected_model), + ) .route("/ws", routing::get(ws::ws_handler)) .fallback_service(ServeDir::new(&static_dir)) .with_state(state.clone()) diff --git a/src/gateway/model_selection.rs b/src/gateway/model_selection.rs index d9459ca..512afc2 100644 --- a/src/gateway/model_selection.rs +++ b/src/gateway/model_selection.rs @@ -16,12 +16,7 @@ impl ModelSelectionStore { } /// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。 - pub fn set( - &self, - session_id: &str, - provider: Option, - model: Option, - ) { + pub fn set(&self, session_id: &str, provider: Option, model: Option) { let mut selections = self .selections .write() @@ -76,9 +71,6 @@ mod tests { fn set_only_provider_keeps_entry() { let store = ModelSelectionStore::new(); store.set("s1", Some("p1".to_string()), None); - assert_eq!( - store.get("s1"), - Some((Some("p1".to_string()), None)) - ); + assert_eq!(store.get("s1"), Some((Some("p1".to_string()), None))); } } diff --git a/src/gateway/processor.rs b/src/gateway/processor.rs index 34a096a..53d8ec2 100644 --- a/src/gateway/processor.rs +++ b/src/gateway/processor.rs @@ -22,7 +22,7 @@ use crate::command::handlers::switch_topic::SwitchTopicCommandHandler; use crate::config::LLMProviderConfig; use crate::gateway::agent_factory::build_system_prompt_provider; use crate::gateway::cancel_manager::CancelManager; -use crate::providers::{create_provider, ProviderRuntimeConfig}; +use crate::providers::{ProviderRuntimeConfig, create_provider}; use crate::storage::persistent_session_id; use crate::topic_description::generate_topic_description; @@ -52,8 +52,8 @@ impl InboundProcessor { let store = session_manager.store(); // 注册 Session 处理器 - let session_handler = SessionCommandHandler::new(store.clone()) - .with_session_manager(session_manager.clone()); + let session_handler = + SessionCommandHandler::new(store.clone()).with_session_manager(session_manager.clone()); command_router.register(Box::new(session_handler)); // 注册 list_sessions 处理器 @@ -79,7 +79,7 @@ impl InboundProcessor { // 注册 get_current 处理器 command_router.register(Box::new( GetCurrentSessionCommandHandler::new(store.clone()) - .with_system_prompt_provider(system_prompt_provider.clone()) + .with_system_prompt_provider(system_prompt_provider.clone()), )); // 注册 load_topic 处理器 @@ -185,7 +185,8 @@ impl InboundProcessor { let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id); // 获取当前话题(封装了 session 创建逻辑) - let current_topic = self.session_manager + let current_topic = self + .session_manager .get_current_topic(&inbound.channel, &inbound.chat_id) .await?; @@ -196,15 +197,19 @@ impl InboundProcessor { if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) { // 使用命令路由器处理 - let mut cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel) - .with_session_id(&session_id) - .with_chat_id(&inbound.chat_id); + let mut cmd_ctx = + crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel) + .with_session_id(&session_id) + .with_chat_id(&inbound.chat_id); // 只在有话题时才设置 topic_id if let Some(ref topic_id) = current_topic { cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str()); } - let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await; + let response = self + .command_router + .dispatch_with_response(cmd, cmd_ctx) + .await; // 发送响应给用户 if response.success { @@ -295,7 +300,9 @@ impl InboundProcessor { outbound.metadata.extend(inbound.forwarded_metadata.clone()); // 注入 topic_id 到 outbound metadata,用于前端按话题隔离消息 if let Some(ref topic_id) = current_topic { - outbound.metadata.insert("topic_id".to_string(), topic_id.clone()); + outbound + .metadata + .insert("topic_id".to_string(), topic_id.clone()); } if let Err(error) = self.bus.publish_outbound(outbound).await { tracing::error!(error = %error, "Failed to publish outbound"); @@ -306,10 +313,17 @@ impl InboundProcessor { if let Some(ref topic_id) = current_topic { let store = self.session_manager.store(); if let Ok(Some(topic)) = store.get_topic(topic_id) { - if topic.description.is_none() || topic.description.as_ref().map(|d| d.is_empty()).unwrap_or(true) { + if topic.description.is_none() + || topic + .description + .as_ref() + .map(|d| d.is_empty()) + .unwrap_or(true) + { // 检查并设置"生成中"守卫,防止竞态条件导致重复生成 let should_generate = { - let mut in_flight = self.description_generation_in_flight.lock().unwrap(); + let mut in_flight = + self.description_generation_in_flight.lock().unwrap(); if in_flight.contains(topic_id) { false } else { @@ -329,7 +343,9 @@ impl InboundProcessor { let first_user_message = store_clone .load_messages_for_topic(&topic_id_clone, None) .ok() - .and_then(|msgs| msgs.into_iter().find(|m| m.role == "user")) + .and_then(|msgs| { + msgs.into_iter().find(|m| m.role == "user") + }) .map(|m| m.content); let message_content = match first_user_message { @@ -341,11 +357,22 @@ impl InboundProcessor { } }; - let runtime_config: ProviderRuntimeConfig = provider_config.into(); + let runtime_config: ProviderRuntimeConfig = + provider_config.into(); if let Ok(provider) = create_provider(runtime_config) { - match generate_topic_description(provider.as_ref(), &message_content).await { + match generate_topic_description( + provider.as_ref(), + &message_content, + ) + .await + { Ok(description) => { - if let Err(e) = store_clone.update_topic_description(&topic_id_clone, &description) { + if let Err(e) = store_clone + .update_topic_description( + &topic_id_clone, + &description, + ) + { tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description"); } else { tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated"); diff --git a/src/gateway/prompt.rs b/src/gateway/prompt.rs index f68d7d7..c5a4d17 100644 --- a/src/gateway/prompt.rs +++ b/src/gateway/prompt.rs @@ -57,8 +57,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result, ensure_parent_dir(path)?; // 文件不存在时创建空白模板 if !path.exists() { - fs::write(path, template) - .map_err(|err| AgentError::Other(format!("create AGENT.md template error: {}", err)))?; + fs::write(path, template).map_err(|err| { + AgentError::Other(format!("create AGENT.md template error: {}", err)) + })?; } // 读取内容,仅当非空(去除注释后)时注入 let content = fs::read_to_string(path) @@ -70,8 +71,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result, } PromptSource::AutoGenerated(path) => { if path.exists() { - let content = fs::read_to_string(path) - .map_err(|err| AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err)))?; + let content = fs::read_to_string(path).map_err(|err| { + AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err)) + })?; let without_comments = strip_comments_and_whitespace(&content); if !without_comments.is_empty() { fragments.push(without_comments); @@ -337,6 +339,9 @@ mod tests { persist_memory_summary(&memory_path, "\n## 用户记忆摘要\n- 偏好简洁\n\n").unwrap(); - assert_eq!(fs::read_to_string(&memory_path).unwrap(), "## 用户记忆摘要\n- 偏好简洁\n"); + assert_eq!( + fs::read_to_string(&memory_path).unwrap(), + "## 用户记忆摘要\n- 偏好简洁\n" + ); } } diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index d5eaef9..a4dafb2 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -8,7 +8,9 @@ use tokio::sync::RwLock; use crate::agent::AgentError; use crate::bus::MessageBus; -use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig}; +use crate::config::{ + LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig, +}; use crate::gateway::model_selection::ModelSelectionStore; use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::mcp::McpInitializer; @@ -18,13 +20,13 @@ use crate::storage::{ ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, SessionStore, SkillEventRepository, TodoRepository, }; -use crate::tools::task::runtime::SubagentRuntime; -use crate::tools::{ - DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, - SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry, -}; use crate::tools::task::repository::TaskRepository; +use crate::tools::task::runtime::SubagentRuntime; use crate::tools::todo_write::TodoItem; +use crate::tools::{ + DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender, + SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry, +}; use super::agent_factory::AgentFactory; use super::cli_session::CliSessionService; @@ -55,7 +57,16 @@ pub(crate) fn build_session_manager( mcp_config: crate::mcp::McpConfig, bus: Option>, model_resolver: Arc, -) -> Result<(SessionManager, Arc, Option>, Arc, Arc), AgentError> { +) -> Result< + ( + SessionManager, + Arc, + Option>, + Arc, + Arc, + ), + AgentError, +> { build_session_manager_with_sender( agent_prompt_reinject_every, show_tool_results, @@ -94,7 +105,16 @@ pub(crate) fn build_session_manager_with_sender( mcp_config: crate::mcp::McpConfig, bus: Option>, model_resolver: Arc, -) -> Result<(SessionManager, Arc, Option>, Arc, Arc), AgentError> { +) -> Result< + ( + SessionManager, + Arc, + Option>, + Arc, + Arc, + ), + AgentError, +> { let store = Arc::new( SessionStore::new() .map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, @@ -181,18 +201,20 @@ pub(crate) fn build_session_manager_with_sender( } // Create SubAgentRuntime (if task tool is enabled) - let (factory, task_repository, subagent_runtime): (_, Arc, Arc) = if task_config.enabled { + let (factory, task_repository, subagent_runtime): ( + _, + Arc, + Arc, + ) = if task_config.enabled { let task_repository = Arc::new(InMemoryTaskRepository::new()); // Build subagent tools with MCP tools (task tool registered separately below) - let subagent_tools = Arc::new( - factory.build_subagent_tools( - if mcp_tools_for_subagents.is_empty() { - None - } else { - Some(mcp_tools_for_subagents.clone()) - } - ) - ); + let subagent_tools = Arc::new(factory.build_subagent_tools( + if mcp_tools_for_subagents.is_empty() { + None + } else { + Some(mcp_tools_for_subagents.clone()) + }, + )); // Create subagent catalog with discovery, wrap in SubagentRuntime let catalog = SubagentCatalog::discover(&subagents_config); @@ -230,11 +252,19 @@ pub(crate) fn build_session_manager_with_sender( )); } - (factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime) + ( + factory.with_subagent_runtime(default_subagent_runtime), + task_repository, + subagent_runtime, + ) } else { // task_config 未启用时仍创建 subagent_runtime(供 API 使用) let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone())); - (factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime) + ( + factory, + Arc::new(InMemoryTaskRepository::new()), + subagent_runtime, + ) }; // Build base tools @@ -306,18 +336,24 @@ pub(crate) fn build_session_manager_with_sender( // Extract MCP manager for lifecycle management (e.g., disconnect on restart) let mcp_manager = mcp_initializer.manager(); - Ok((SessionManager::from_services(SessionManagerServices { - tools: tools as Arc, - skills, - experts, - subagent_runtime: subagent_runtime.clone(), - store, - show_tool_results, - lifecycle, - cli_sessions, - messages, - scheduled_tasks, - memory_maintenance, - task_repository: task_repository.clone(), - }), task_repository, mcp_manager, subagent_runtime, model_selections)) + Ok(( + SessionManager::from_services(SessionManagerServices { + tools: tools as Arc, + skills, + experts, + subagent_runtime: subagent_runtime.clone(), + store, + show_tool_results, + lifecycle, + cli_sessions, + messages, + scheduled_tasks, + memory_maintenance, + task_repository: task_repository.clone(), + }), + task_repository, + mcp_manager, + subagent_runtime, + model_selections, + )) } diff --git a/src/gateway/scheduled_agent_task_service.rs b/src/gateway/scheduled_agent_task_service.rs index 66bd633..38e2359 100644 --- a/src/gateway/scheduled_agent_task_service.rs +++ b/src/gateway/scheduled_agent_task_service.rs @@ -37,7 +37,10 @@ impl ScheduledAgentTaskService { // 根据 chat_id 自动选择 Session: // - scheduler/ 开头:使用定时任务专用 Session(独立实例,不与用户消息竞争锁) // - 其他:使用主 Session - let session = self.lifecycle.active_session_for_chat_id(channel_name, chat_id).await?; + let session = self + .lifecycle + .active_session_for_chat_id(channel_name, chat_id) + .await?; let sender_id = options .sender_id .clone() diff --git a/src/gateway/session.rs b/src/gateway/session.rs index cbf5a2e..1dfd678 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -2,12 +2,15 @@ use crate::agent::{AgentError, AgentLoop, ContextCompressor, EmittedMessageHandl #[cfg(test)] use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT; use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; -use crate::providers::StreamDelta; use crate::config::LLMProviderConfig; use crate::protocol::WsOutbound; +use crate::providers::StreamDelta; use crate::scheduler::ScheduledAgentTaskOptions; use crate::skills::SkillRuntime; -use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository}; +use crate::storage::{ + ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, + SkillEventRepository, +}; use crate::tools::ToolRegistry; use crate::tools::task::repository::TaskRepository; use crate::tools::task::runtime::SubagentRuntime; @@ -24,8 +27,7 @@ use super::execution::should_display_message_to_user; #[cfg(test)] use super::memory_maintenance::{ MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan, - extract_json_object, is_recoverable_maintenance_llm_error, - strip_json_code_fence, + extract_json_object, is_recoverable_maintenance_llm_error, strip_json_code_fence, }; use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput}; use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator; @@ -125,7 +127,9 @@ impl EmittedMessageHandler for BusToolCallEmitter { // Get or create the stream message ID let message_id = { let mut guard = self.stream_message_id.lock().unwrap(); - guard.get_or_insert_with(|| Uuid::new_v4().to_string()).clone() + guard + .get_or_insert_with(|| Uuid::new_v4().to_string()) + .clone() }; // Empty content + no reasoning = stream end signal @@ -180,7 +184,11 @@ impl BusToolCallEmitter { .cloned() .unwrap_or_else(|| session_id.clone()); - let topic_id = self.metadata.get("topic_id").filter(|t| !t.is_empty()).cloned(); + let topic_id = self + .metadata + .get("topic_id") + .filter(|t| !t.is_empty()) + .cloned(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -304,11 +312,7 @@ impl Session { skills, agent_factory, compressor: ContextCompressor::from_provider_config(&provider_config), - history: SessionHistory::new( - channel_name, - conversations, - skill_events, - ), + history: SessionHistory::new(channel_name, conversations, skill_events), store, pending_cancel_tokens: HashMap::new(), }) @@ -586,7 +590,7 @@ impl Session { ) -> Result { self.create_agent_with_provider_config( chat_id, - None, // notification_chat_id = None,使用 session_chat_id + None, // notification_chat_id = None,使用 session_chat_id sender_id, message_id, self.provider_config.clone(), @@ -611,7 +615,9 @@ impl Session { // 消费 pending 的取消信号接收端(如果存在) // 优先按 topic_id 查找;无 topic 时回退 chat_id let cancel_token = match &topic_id { - Some(tid) => self.pending_cancel_tokens.remove(tid) + Some(tid) => self + .pending_cancel_tokens + .remove(tid) .or_else(|| self.pending_cancel_tokens.remove(session_chat_id)), None => self.pending_cancel_tokens.remove(session_chat_id), }; @@ -780,7 +786,11 @@ impl SessionManager { } /// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复) - pub async fn get_current_topic(&self, channel_name: &str, chat_id: &str) -> Result, AgentError> { + pub async fn get_current_topic( + &self, + channel_name: &str, + chat_id: &str, + ) -> Result, AgentError> { self.ensure_session(channel_name).await?; if let Some(session) = self.get(channel_name).await { let mut guard = session.lock().await; @@ -788,7 +798,9 @@ impl SessionManager { // 如果内存中没有当前话题,从数据库恢复最近活跃的话题 if guard.current_topic(chat_id).is_none() { let session_id = guard.persistent_session_id(chat_id); - let topics = self.store.list_topics(&session_id) + let topics = self + .store + .list_topics(&session_id) .map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?; if let Some(latest_topic) = topics.first() { @@ -802,10 +814,7 @@ impl SessionManager { ); } else { // 数据库中也没有话题,自动创建默认话题 - let title = format!( - "话题 {}", - chrono::Local::now().format("%m/%d %H:%M") - ); + let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M")); match self.store.create_topic(&session_id, &title, None) { Ok(topic) => { guard.set_current_topic(chat_id, Some(topic.id.clone())); @@ -845,7 +854,10 @@ impl SessionManager { token: tokio::sync::watch::Receiver<()>, ) { if let Some(session) = self.get(channel_name).await { - session.lock().await.set_cancel_receiver(chat_id, topic_id, token); + session + .lock() + .await + .set_cancel_receiver(chat_id, topic_id, token); } } @@ -904,7 +916,13 @@ impl SessionManager { options: ScheduledAgentTaskOptions, ) -> Result, AgentError> { self.scheduled_tasks - .run(channel_name, session_chat_id, notification_chat_id, prompt, options) + .run( + channel_name, + session_chat_id, + notification_chat_id, + prompt, + options, + ) .await } @@ -972,9 +990,9 @@ mod tests { store.clone(), store.clone(), Arc::new(NoopSessionMessageSender), - HashSet::new(), + HashSet::new(), "Asia/Shanghai".to_string(), - HashSet::new(), + HashSet::new(), Default::default(), ) .build(), @@ -1000,12 +1018,16 @@ mod tests { let first = session.create_user_message("first", Vec::new()); let first_id = first.id.clone(); - session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap(); + session + .append_persisted_message("chat-1", Some(&topic_id), first) + .unwrap(); assert!(session.is_latest_user_message(&topic_id, &first_id)); let second = session.create_user_message("second", Vec::new()); let second_id = second.id.clone(); - session.append_persisted_message("chat-1", Some(&topic_id), second).unwrap(); + session + .append_persisted_message("chat-1", Some(&topic_id), second) + .unwrap(); assert!(!session.is_latest_user_message(&topic_id, &first_id)); assert!(session.is_latest_user_message(&topic_id, &second_id)); @@ -1024,9 +1046,9 @@ mod tests { store.clone(), store.clone(), Arc::new(NoopSessionMessageSender), - HashSet::new(), + HashSet::new(), "Asia/Shanghai".to_string(), - HashSet::new(), + HashSet::new(), Default::default(), ) .build(), @@ -1052,9 +1074,15 @@ mod tests { let first = session.create_user_message("first", Vec::new()); let first_id = first.id.clone(); - session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap(); session - .append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-1")) + .append_persisted_message("chat-1", Some(&topic_id), first) + .unwrap(); + session + .append_persisted_message( + "chat-1", + Some(&topic_id), + ChatMessage::assistant("answer-1"), + ) .unwrap(); let second = session.create_user_message("second", Vec::new()); @@ -1062,7 +1090,11 @@ mod tests { .append_persisted_message("chat-1", Some(&topic_id), second.clone()) .unwrap(); session - .append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-2")) + .append_persisted_message( + "chat-1", + Some(&topic_id), + ChatMessage::assistant("answer-2"), + ) .unwrap(); let preserved_messages = session.get_history(&topic_id).unwrap().clone(); @@ -1235,7 +1267,15 @@ mod tests { .unwrap(); let outbound = session_manager - .handle_message("test-channel", "user-1", "chat-1", "hello", Vec::new(), None, None) + .handle_message( + "test-channel", + "user-1", + "chat-1", + "hello", + Vec::new(), + None, + None, + ) .await .unwrap(); @@ -1733,7 +1773,8 @@ mod tests { } #[tokio::test] - async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates() { + async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates() + { let mock_response_content = serde_json::to_string(&json!({ "user_facts": ["用户在做AI产品"], "preferences": [], @@ -1983,11 +2024,18 @@ mod tests { let all_memories = store.list_memories_for_scope("user", scope_key).unwrap(); // 过滤掉 _meta 记录 - let user_memories: Vec<_> = all_memories.iter().filter(|m| m.namespace != "_meta").collect(); + let user_memories: Vec<_> = all_memories + .iter() + .filter(|m| m.namespace != "_meta") + .collect(); // 合并 2 条为 1 条,删除 1 条,7 - 2 + 1 = 5 条 assert_eq!(user_memories.len(), 5); // 验证合并后的记忆存在 - assert!(user_memories.iter().any(|m| m.namespace == "user" && m.memory_key == "work")); + assert!( + user_memories + .iter() + .any(|m| m.namespace == "user" && m.memory_key == "work") + ); } #[test] @@ -2010,22 +2058,19 @@ mod tests { let store = Arc::new(SessionStore::in_memory().unwrap()); let bus = MessageBus::new(4); let emitter = - BusToolCallEmitter::new( - bus.clone(), - "test-channel", - "chat-1", - HashMap::new(), - store, - ); + BusToolCallEmitter::new(bus.clone(), "test-channel", "chat-1", HashMap::new(), store); emitter .handle(ChatMessage::tool("call-1", "calculator", "2")) .await; - let msg = tokio::time::timeout(std::time::Duration::from_millis(500), bus.consume_outbound()) - .await - .expect("timeout waiting for outbound message") - .expect("bus outbound closed"); + let msg = tokio::time::timeout( + std::time::Duration::from_millis(500), + bus.consume_outbound(), + ) + .await + .expect("timeout waiting for outbound message") + .expect("bus outbound closed"); assert_eq!(msg.event_kind, OutboundEventKind::ToolResult); } @@ -2042,9 +2087,9 @@ mod tests { store.clone(), store.clone(), Arc::new(NoopSessionMessageSender), - HashSet::new(), + HashSet::new(), "Asia/Shanghai".to_string(), - HashSet::new(), + HashSet::new(), Default::default(), ) .build(), @@ -2083,9 +2128,9 @@ mod tests { store.clone(), store.clone(), Arc::new(NoopSessionMessageSender), - HashSet::new(), + HashSet::new(), "Asia/Shanghai".to_string(), - HashSet::new(), + HashSet::new(), Default::default(), ) .build(), @@ -2111,7 +2156,11 @@ mod tests { for turn in 0..100 { session - .append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}"))) + .append_persisted_message( + "chat-1", + Some(&topic_id), + ChatMessage::user(format!("user-{turn}")), + ) .unwrap(); } @@ -2160,9 +2209,9 @@ mod tests { store.clone(), store.clone(), Arc::new(NoopSessionMessageSender), - HashSet::new(), + HashSet::new(), "Asia/Shanghai".to_string(), - HashSet::new(), + HashSet::new(), Default::default(), ) .build(), @@ -2188,7 +2237,11 @@ mod tests { for turn in 0..100 { session - .append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}"))) + .append_persisted_message( + "chat-1", + Some(&topic_id), + ChatMessage::user(format!("user-{turn}")), + ) .unwrap(); } diff --git a/src/gateway/session_history.rs b/src/gateway/session_history.rs index aa3437c..0e429fa 100644 --- a/src/gateway/session_history.rs +++ b/src/gateway/session_history.rs @@ -115,7 +115,9 @@ impl SessionHistory { } pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec { - self.topic_histories.entry(topic_id.to_string()).or_default() + self.topic_histories + .entry(topic_id.to_string()) + .or_default() } pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec> { diff --git a/src/gateway/session_lifecycle.rs b/src/gateway/session_lifecycle.rs index 3d02ead..f2314a7 100644 --- a/src/gateway/session_lifecycle.rs +++ b/src/gateway/session_lifecycle.rs @@ -52,9 +52,12 @@ impl SessionLifecycleService { channel_name: &str, chat_id: &str, ) -> Result>, AgentError> { - self.session_pool.ensure_session_for_chat_id(channel_name, chat_id).await?; + self.session_pool + .ensure_session_for_chat_id(channel_name, chat_id) + .await?; self.touch(channel_name).await; - self.session_pool.get_for_chat_id(channel_name, chat_id) + self.session_pool + .get_for_chat_id(channel_name, chat_id) .await .ok_or_else(|| AgentError::Other("Session not found".to_string())) } diff --git a/src/gateway/session_message_sender.rs b/src/gateway/session_message_sender.rs index cc8c1db..5bcaf2c 100644 --- a/src/gateway/session_message_sender.rs +++ b/src/gateway/session_message_sender.rs @@ -122,7 +122,7 @@ mod tests { // 使用临时目录确保跨平台兼容 attachments: vec![MediaItem::new( &std::env::temp_dir().join("demo.png").display().to_string(), - "image" + "image", )], }, ) @@ -143,4 +143,4 @@ mod tests { assert_eq!(msg.media.len(), 1); assert_eq!(msg.media[0].media_type, "image"); } -} \ No newline at end of file +} diff --git a/src/gateway/session_pool.rs b/src/gateway/session_pool.rs index 762202b..f30620c 100644 --- a/src/gateway/session_pool.rs +++ b/src/gateway/session_pool.rs @@ -49,7 +49,10 @@ impl SessionPool { } /// 确保定时任务专用 Session 存在 - pub(crate) async fn ensure_scheduler_session(&self, channel_name: &str) -> Result<(), AgentError> { + pub(crate) async fn ensure_scheduler_session( + &self, + channel_name: &str, + ) -> Result<(), AgentError> { self.ensure_session_internal(channel_name, true).await } @@ -59,7 +62,11 @@ impl SessionPool { /// session 创建(含配置加载、agent 工厂构造),再次持锁插入并处理竞态。 /// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的 /// session 访问串行化。 - async fn ensure_session_internal(&self, channel_name: &str, is_scheduler: bool) -> Result<(), AgentError> { + async fn ensure_session_internal( + &self, + channel_name: &str, + is_scheduler: bool, + ) -> Result<(), AgentError> { // Fast path: 已存在直接返回(短暂持锁) { let inner = self.inner.lock().await; @@ -109,14 +116,26 @@ impl SessionPool { } /// 获取定时任务专用 Session - pub(crate) async fn get_scheduler_session(&self, channel_name: &str) -> Option>> { - self.inner.lock().await.scheduler_sessions.get(channel_name).cloned() + pub(crate) async fn get_scheduler_session( + &self, + channel_name: &str, + ) -> Option>> { + self.inner + .lock() + .await + .scheduler_sessions + .get(channel_name) + .cloned() } /// 根据 chat_id 自动选择 Session /// - scheduler/ 开头:返回定时任务专用 Session /// - 其他:返回主 Session - pub(crate) async fn get_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Option>> { + pub(crate) async fn get_for_chat_id( + &self, + channel_name: &str, + chat_id: &str, + ) -> Option>> { if is_scheduler_chat_id(chat_id) { self.get_scheduler_session(channel_name).await } else { @@ -125,7 +144,11 @@ impl SessionPool { } /// 确保 Session 存在(根据 chat_id 自动选择) - pub(crate) async fn ensure_session_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Result<(), AgentError> { + pub(crate) async fn ensure_session_for_chat_id( + &self, + channel_name: &str, + chat_id: &str, + ) -> Result<(), AgentError> { if is_scheduler_chat_id(chat_id) { self.ensure_scheduler_session(channel_name).await } else { diff --git a/src/gateway/static_files.rs b/src/gateway/static_files.rs index 1b6b790..55ce6e2 100644 --- a/src/gateway/static_files.rs +++ b/src/gateway/static_files.rs @@ -1,6 +1,6 @@ use axum::{ body::Body, - http::{header, Response, StatusCode, Uri}, + http::{Response, StatusCode, Uri, header}, }; use rust_embed::RustEmbed; @@ -16,11 +16,7 @@ pub async fn static_handler(uri: Uri) -> Response { let path = uri.path().trim_start_matches('/'); // 处理根路径,返回 index.html - let path = if path.is_empty() { - "index.html" - } else { - path - }; + let path = if path.is_empty() { "index.html" } else { path }; match StaticAssets::get(path) { Some(content) => { @@ -54,4 +50,4 @@ pub async fn static_handler(uri: Uri) -> Response { .unwrap() } } -} \ No newline at end of file +} diff --git a/src/gateway/tool_registry_factory.rs b/src/gateway/tool_registry_factory.rs index 772cc99..eeec384 100644 --- a/src/gateway/tool_registry_factory.rs +++ b/src/gateway/tool_registry_factory.rs @@ -6,13 +6,14 @@ use tokio::sync::RwLock; use crate::config::TaskConfig; use crate::mcp::McpClientManager; use crate::skills::SkillRuntime; -use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository}; +use crate::storage::{ + MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository, +}; use crate::tools::todo_write::TodoItem; use crate::tools::{ - BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, - HttpRequestTool, MemoryManageTool, MemorySearchTool, - SchedulerManageTool, SessionMessageSender, SessionSendTool, ShellSessionManager, - SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool, + BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool, + MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, SessionSendTool, + ShellSessionManager, SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool, TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool, }; @@ -72,18 +73,12 @@ impl ToolRegistryFactory { self } - pub(crate) fn with_subagent_runtime( - mut self, - runtime: Arc, - ) -> Self { + pub(crate) fn with_subagent_runtime(mut self, runtime: Arc) -> Self { self.subagent_runtime = Some(runtime); self } - pub(crate) fn with_mcp_manager( - mut self, - manager: Arc, - ) -> Self { + pub(crate) fn with_mcp_manager(mut self, manager: Arc) -> Self { self.mcp_manager = Some(manager); self } @@ -118,8 +113,14 @@ impl ToolRegistryFactory { } if self.is_enabled("todo_write") { if let Some(ref state) = self.todo_state { - registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone())); - registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone())); + registry.register(TodoWriteTool::new( + state.clone(), + self.todo_repository.clone(), + )); + registry.register(TodoReadTool::new( + state.clone(), + self.todo_repository.clone(), + )); } } if self.is_enabled("session_send") { @@ -226,8 +227,14 @@ impl ToolRegistryFactory { // Todo 追踪工具 if self.is_enabled("todo_write") { if let Some(ref state) = self.todo_state { - registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone())); - registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone())); + registry.register(TodoWriteTool::new( + state.clone(), + self.todo_repository.clone(), + )); + registry.register(TodoReadTool::new( + state.clone(), + self.todo_repository.clone(), + )); } } diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 9ae8f7b..a5ddcd3 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -10,16 +10,16 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::list_channels::ListChannelsCommandHandler; use crate::command::handlers::list_memories::ListMemoriesCommandHandler; -use crate::command::handlers::list_skills::ListSkillsCommandHandler; use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler; -use crate::command::handlers::list_todos::ListTodosCommandHandler; -use crate::command::handlers::memory_crud::MemoryCrudCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; +use crate::command::handlers::list_skills::ListSkillsCommandHandler; +use crate::command::handlers::list_todos::ListTodosCommandHandler; use crate::command::handlers::list_topics::ListTopicsCommandHandler; use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler; use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler; +use crate::command::handlers::memory_crud::MemoryCrudCommandHandler; use crate::command::handlers::rename_topic::RenameTopicCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler; @@ -27,7 +27,7 @@ use crate::command::handlers::session::SessionCommandHandler; use crate::command::handlers::stop_execution::StopExecutionCommandHandler; use crate::command::handlers::switch_topic::SwitchTopicCommandHandler; use crate::gateway::agent_factory::build_system_prompt_provider; -use crate::protocol::{WsInbound, WsOutbound, MediaSummary, parse_inbound, serialize_outbound}; +use crate::protocol::{MediaSummary, WsInbound, WsOutbound, parse_inbound, serialize_outbound}; use crate::storage::persistent_session_id; use crate::tools::task::repository::TaskRepository; use crate::tools::task::types::TaskSessionState; @@ -68,7 +68,9 @@ fn build_media_filename(media_type: &str, file_name: Option<&str>) -> String { /// Process attachments with base64 content: save to local file and return MediaItem with correct path /// Keeps content_base64 for frontend display/download -fn process_attachments_with_base64(attachments: Vec) -> Result, AgentError> { +fn process_attachments_with_base64( + attachments: Vec, +) -> Result, AgentError> { if attachments.is_empty() { return Ok(Vec::new()); } @@ -82,15 +84,16 @@ fn process_attachments_with_base64(attachments: Vec) -> Result) { let store = state.session_manager.store(); // 1. 查询 websocket 和 cli 两个通道的 Sessions(兼容旧版本 cli 通道创建的会话) - let mut websocket_sessions = store.list_sessions("websocket", false) - .unwrap_or_default(); - let cli_channel_sessions = store.list_sessions("cli", false) - .unwrap_or_default(); + let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default(); + let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default(); websocket_sessions.extend(cli_channel_sessions); websocket_sessions.sort_by_key(|s| -(s.last_active_at)); @@ -180,9 +181,7 @@ async fn handle_socket(ws: WebSocket, state: Arc) { // 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道) let channels = state.channel_manager.build_channel_list().await; - let _ = sender - .send(WsOutbound::ChannelList { channels }) - .await; + let _ = sender.send(WsOutbound::ChannelList { channels }).await; // 3. 发送合并后的 Session 列表(已在上面合并了 websocket + cli 通道) // 如果刚创建了新会话,确保它也在列表中 @@ -304,7 +303,6 @@ async fn handle_socket(ws: WebSocket, state: Arc) { tracing::info!(session_id = %runtime_session_id, current_session_id = %current_session_id, "CLI session ended"); } - async fn handle_inbound( state: &Arc, sender: &mpsc::Sender, @@ -394,7 +392,11 @@ async fn handle_inbound( let store = state.session_manager.store(); let skills = state.session_manager.skills(); let skills_for_handler = skills.clone(); - let provider_config = state.config.read().await.get_provider_config("default") + let provider_config = state + .config + .read() + .await + .get_provider_config("default") .map_err(|e| AgentError::Other(e.to_string()))?; let prompt_repository = state.session_manager.store().clone(); @@ -417,9 +419,13 @@ async fn handle_inbound( // 注册 list_sessions 处理器 router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); // 注册 list_sessions_by_channel 处理器 - router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone()))); + router.register(Box::new(ListSessionsByChannelCommandHandler::new( + store.clone(), + ))); // 注册 list_channels 处理器 - router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone())))); + router.register(Box::new(ListChannelsCommandHandler::new(Arc::new( + state.channel_manager.clone(), + )))); // 注册 list_topics 处理器 router.register(Box::new(ListTopicsCommandHandler::new(store.clone()))); // 注册 switch_topic 处理器 @@ -460,7 +466,9 @@ async fn handle_inbound( let metadata = router.metadata_arc(); router.register(Box::new(HelpCommandHandler::new(metadata))); // 注册 list_scheduler_jobs 处理器 - router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone()))); + router.register(Box::new(ListSchedulerJobsCommandHandler::new( + store.clone(), + ))); // 注册 list_memories 处理器 router.register(Box::new(ListMemoriesCommandHandler::new(store.clone()))); // 注册 list_skills 处理器 @@ -524,51 +532,95 @@ async fn handle_inbound( *current_topic_id = Some(topic_id.clone()); // 加载并发送该话题的历史消息 - if let Err(e) = send_topic_history(&store, current_session_id, topic_id, sender, &state.task_repository).await { + if let Err(e) = send_topic_history( + &store, + current_session_id, + topic_id, + sender, + &state.task_repository, + ) + .await + { tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history"); } } // 加载子智能体任务消息 if let Some(task_session_id) = response.metadata.get("task_session_id") { // 提前提取 task_id,用于给历史消息打标记 - let task_id = response.metadata.get("task_id").cloned().unwrap_or_default(); - if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await { + let task_id = response + .metadata + .get("task_id") + .cloned() + .unwrap_or_default(); + if let Err(e) = send_task_messages( + &store, + task_session_id, + sender, + Some(task_id.clone()), + Some(&state.task_repository), + ) + .await + { tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages"); } // 发送 TaskMessagesLoaded 元数据 - let description = response.metadata.get("task_description").cloned().unwrap_or_default(); - let subagent_type = response.metadata.get("task_subagent_type").cloned().unwrap_or_default(); - let status = response.metadata.get("task_status").cloned().unwrap_or_default(); + let description = response + .metadata + .get("task_description") + .cloned() + .unwrap_or_default(); + let subagent_type = response + .metadata + .get("task_subagent_type") + .cloned() + .unwrap_or_default(); + let status = response + .metadata + .get("task_status") + .cloned() + .unwrap_or_default(); let summary = response.metadata.get("task_summary").cloned(); - let _ = sender.send(WsOutbound::TaskMessagesLoaded { - task_id, - description, - subagent_type, - status, - summary, - }).await; + let _ = sender + .send(WsOutbound::TaskMessagesLoaded { + task_id, + description, + subagent_type, + status, + summary, + }) + .await; } // 处理定时任务列表 if let Some(jobs_json) = response.metadata.get("scheduler_jobs") { - if let Ok(jobs) = serde_json::from_str::>(jobs_json) { + if let Ok(jobs) = + serde_json::from_str::>(jobs_json) + { let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await; } } // 处理技能列表 if let Some(skills_json) = response.metadata.get("skills") { - if let Ok(skills) = serde_json::from_str::>(skills_json) { + if let Ok(skills) = + serde_json::from_str::>(skills_json) + { let _ = sender.send(WsOutbound::SkillList { skills }).await; } } // 处理 Todo 列表 if let Some(todos_json) = response.metadata.get("todos") { - if let Ok(todos) = serde_json::from_str::>(todos_json) { - let scope_key = response.metadata.get("todos_scope_key").cloned().unwrap_or_default(); + if let Ok(todos) = + serde_json::from_str::>(todos_json) + { + let scope_key = response + .metadata + .get("todos_scope_key") + .cloned() + .unwrap_or_default(); tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response"); let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await; } @@ -576,14 +628,18 @@ async fn handle_inbound( // 处理记忆列表 if let Some(memories_json) = response.metadata.get("memories") { - if let Ok(memories) = serde_json::from_str::>(memories_json) { + if let Ok(memories) = + serde_json::from_str::>(memories_json) + { let _ = sender.send(WsOutbound::MemoryList { memories }).await; } } // 记忆 CRUD 后自动刷新列表 if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") { - if let Ok(records) = store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) { + if let Ok(records) = + store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) + { let memories: Vec = records .into_iter() .filter(|m| m.namespace != "_meta") @@ -602,15 +658,17 @@ async fn handle_inbound( // 处理加载聊天消息请求 if let Some(load_chat_id) = response.metadata.get("load_chat_id") { - let load_chat_channel = response.metadata.get("load_chat_channel") + let load_chat_channel = response + .metadata + .get("load_chat_channel") .cloned() .unwrap_or_default(); // session_id = "{channel}:{chat_id}" (cli channel 例外) - let session_id = crate::storage::persistent_session_id( - &load_chat_channel, - load_chat_id, - ); - if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await { + let session_id = + crate::storage::persistent_session_id(&load_chat_channel, load_chat_id); + if let Err(e) = + send_task_messages(&store, &session_id, sender, None, None).await + { tracing::warn!( error = %e, channel = %load_chat_channel, @@ -623,12 +681,22 @@ async fn handle_inbound( if current_topic_id.is_none() { if let Some(topics_json) = response.metadata.get("topics") { - match serde_json::from_str::>(topics_json) { + match serde_json::from_str::>( + topics_json, + ) { Ok(topics) => { if let Some(first_topic) = topics.first() { let topic_id = first_topic.topic_id.clone(); *current_topic_id = Some(topic_id.clone()); - if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender, &state.task_repository).await { + if let Err(e) = send_topic_history( + &store, + current_session_id, + &topic_id, + sender, + &state.task_repository, + ) + .await + { tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history"); } } @@ -865,10 +933,15 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec let media_type = mime_type .as_ref() .map(|m| { - if m.starts_with("image/") { "image" } - else if m.starts_with("audio/") { "audio" } - else if m.starts_with("video/") { "video" } - else { "file" } + if m.starts_with("image/") { + "image" + } else if m.starts_with("audio/") { + "audio" + } else if m.starts_with("video/") { + "video" + } else { + "file" + } }) .unwrap_or("file"); @@ -892,7 +965,8 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec "assistant" => { if let Some(tool_calls) = &msg.tool_calls { let mut outbound = Vec::new(); - let has_content_or_reasoning = !msg.content.trim().is_empty() || msg.reasoning_content.is_some(); + let has_content_or_reasoning = + !msg.content.trim().is_empty() || msg.reasoning_content.is_some(); if has_content_or_reasoning { outbound.push(WsOutbound::AssistantResponse { id: msg.id.clone(), @@ -907,7 +981,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec }); } // AssistantResponse 已携带 reasoning 时,ToolCall 不再重复 - let tc_reasoning = if has_content_or_reasoning { None } else { msg.reasoning_content.clone() }; + let tc_reasoning = if has_content_or_reasoning { + None + } else { + msg.reasoning_content.clone() + }; for tool_call in tool_calls { outbound.push(WsOutbound::ToolCall { id: tool_call.id.clone(), @@ -940,10 +1018,16 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec } } "tool" => { - let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed); + let tool_state = msg + .tool_state + .as_ref() + .unwrap_or(&ToolMessageState::Completed); match tool_state { ToolMessageState::Completed => vec![WsOutbound::ToolResult { - id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + id: msg + .tool_call_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), tool_call_id: msg.tool_call_id.clone().unwrap_or_default(), tool_name: msg.tool_name.clone().unwrap_or_default(), content: msg.content.clone(), @@ -954,7 +1038,10 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec timestamp: Some(msg.timestamp / 1000), }], ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { - id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + id: msg + .tool_call_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), tool_call_id: msg.tool_call_id.clone().unwrap_or_default(), tool_name: msg.tool_name.clone().unwrap_or_default(), content: msg.content.clone(), @@ -983,7 +1070,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec #[cfg(test)] mod tests { - use super::{resolve_ws_sender_id, build_media_filename, process_attachments_with_base64}; + use super::{build_media_filename, process_attachments_with_base64, resolve_ws_sender_id}; use crate::protocol::MediaSummary; use base64::{Engine as _, engine::general_purpose::STANDARD}; diff --git a/src/lib.rs b/src/lib.rs index ae8ec53..b5b7505 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,5 +20,5 @@ pub mod scheduler; pub mod skills; pub mod storage; pub mod text; -pub mod topic_description; pub mod tools; +pub mod topic_description; diff --git a/src/logging.rs b/src/logging.rs index 4eb23d3..4031cdb 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -45,7 +45,9 @@ pub fn init_logging(timezone: Tz) { static INIT: Once = Once::new(); let mut initialized = false; - INIT.call_once(|| { initialized = true; }); + INIT.call_once(|| { + initialized = true; + }); if !initialized { // Already initialized (e.g. after gateway restart), skip return; diff --git a/src/main.rs b/src/main.rs index fd5da21..080edf6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,11 +40,14 @@ async fn main() -> Result<(), Box> { if std::env::args().len() <= 1 { cmd.print_help()?; println!(); - return Ok(()) + return Ok(()); } match Command::parse() { - Command::Init { force, skip_channels } => { + Command::Init { + force, + skip_channels, + } => { let mut wizard = picobot::cli::InitWizard::new(); wizard.run(force, skip_channels).await?; } @@ -56,12 +59,12 @@ async fn main() -> Result<(), Box> { picobot::client::run(&url).await?; } Command::Gateway { host, port } => { - loop { - let should_restart = picobot::gateway::run(host.clone(), port).await?; - if !should_restart { - break; + let mut should_restart = true; + while should_restart { + should_restart = picobot::gateway::run(host.clone(), port).await?; + if should_restart { + tracing::info!("Gateway restarting..."); } - tracing::info!("Gateway restarting..."); } } } diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 49fd0b5..6c13f97 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -10,14 +10,16 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::RwLock; +use http::{HeaderName, HeaderValue}; use rmcp::{ - model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool}, RoleClient, ServiceExt, + model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool}, service::RunningService, transport::TokioChildProcess, - transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig}, + transport::streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + }, }; -use http::{HeaderName, HeaderValue}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; @@ -64,7 +66,11 @@ fn resolve_command_path(command: &str) -> Option { } // If it has a path separator (relative path), don't search PATH - if path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false) { + if path + .parent() + .map(|p| !p.as_os_str().is_empty()) + .unwrap_or(false) + { return None; } @@ -208,7 +214,10 @@ impl McpClientManager { "Failed to connect to MCP server after all retries" ); // Record error for status reporting - self.connection_errors.write().await.insert(key.clone(), e.to_string()); + self.connection_errors + .write() + .await + .insert(key.clone(), e.to_string()); failed += 1; } else { // Clear any previous error on successful connection @@ -238,18 +247,23 @@ impl McpClientManager { } /// Connect to a single MCP server - pub async fn connect_server(&self, key: &str, config: &McpServerConfig) -> anyhow::Result { + pub async fn connect_server( + &self, + key: &str, + config: &McpServerConfig, + ) -> anyhow::Result { let effective_name = config.effective_name(key); tracing::info!(key = %key, name = %effective_name, transport_type = %config.transport_type, "Connecting to MCP server"); let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?; let client = match transport { - McpTransportConfig::Stdio { command, args, env, cwd } => { - self.connect_stdio(key, &command, &args, &env, &cwd).await? - } - McpTransportConfig::Http { url, headers } => { - self.connect_http(&url, &headers).await? - } + McpTransportConfig::Stdio { + command, + args, + env, + cwd, + } => self.connect_stdio(key, &command, &args, &env, &cwd).await?, + McpTransportConfig::Http { url, headers } => self.connect_http(&url, &headers).await?, }; // Get server info (returns Option> in rmcp 1.8+) @@ -299,7 +313,10 @@ impl McpClientManager { if resolved_command.is_none() { let path = std::path::Path::new(command); let is_absolute = path.is_absolute(); - let has_separator = path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false); + let has_separator = path + .parent() + .map(|p| !p.as_os_str().is_empty()) + .unwrap_or(false); if !is_absolute && !has_separator && path.extension().is_none() { // Bare name not found on Windows let path_env = std::env::var("PATH").unwrap_or_default(); @@ -308,7 +325,8 @@ impl McpClientManager { Current PATH: {}. \ Suggestion: use the full absolute path to the executable, \ or ensure the tool is installed and its directory is in PATH.", - command, path_env + command, + path_env )); } } @@ -416,7 +434,8 @@ impl McpClientManager { })?; // Track that we have a stdio (child process) connection - self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.stdio_client_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(client) } @@ -446,25 +465,21 @@ impl McpClientManager { .iter() .filter_map(|(key, value)| { // Try to parse header name and value - HeaderName::try_from(key.clone()) - .ok() - .and_then(|name| { - HeaderValue::try_from(value.clone()) - .ok() - .map(|val| (name, val)) - }) + HeaderName::try_from(key.clone()).ok().and_then(|name| { + HeaderValue::try_from(value.clone()) + .ok() + .map(|val| (name, val)) + }) }) .collect(); // Create transport config with custom headers - let config = StreamableHttpClientTransportConfig::with_uri(url) - .custom_headers(custom_headers); + let config = + StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers); // Create transport using reqwest client (default) - let transport = StreamableHttpClientTransport::with_client( - reqwest::Client::default(), - config, - ); + let transport = + StreamableHttpClientTransport::with_client(reqwest::Client::default(), config); // Connect let client = ().serve(transport).await?; @@ -497,7 +512,9 @@ impl McpClientManager { info_map .values() .flat_map(|info| { - info.tools.iter().map(|tool| (info.key.clone(), tool.clone())) + info.tools + .iter() + .map(|tool| (info.key.clone(), tool.clone())) }) .collect() } @@ -561,7 +578,9 @@ impl McpClientManager { /// gateway restart where old MCP processes may still be running when /// new ones start. pub async fn shutdown_all(&self) -> anyhow::Result<()> { - let stdio_count = self.stdio_client_count.load(std::sync::atomic::Ordering::SeqCst); + let stdio_count = self + .stdio_client_count + .load(std::sync::atomic::Ordering::SeqCst); // Drop all clients (triggers cancellation + graceful shutdown in rmcp) self.disconnect_all().await?; @@ -578,7 +597,8 @@ impl McpClientManager { ); tokio::time::sleep(std::time::Duration::from_secs(6)).await; tracing::info!("MCP child process cleanup wait complete"); - self.stdio_client_count.store(0, std::sync::atomic::Ordering::SeqCst); + self.stdio_client_count + .store(0, std::sync::atomic::Ordering::SeqCst); } Ok(()) @@ -766,7 +786,10 @@ impl McpInitializer { /// /// This should be called after the gateway is ready to accept tools. /// Waits for connections to complete before registering tools. - pub async fn register_tools(&mut self, registry: &mut crate::tools::ToolRegistry) -> anyhow::Result<()> { + pub async fn register_tools( + &mut self, + registry: &mut crate::tools::ToolRegistry, + ) -> anyhow::Result<()> { if let Some(manager) = self.manager.clone() { // Wait for connections to complete first self.wait_for_connections().await?; @@ -789,9 +812,15 @@ mod tests { // On all platforms, this should return None (either because it's not found, // or because non-Windows always returns None) #[cfg(windows)] - assert!(result.is_none(), "Expected None for nonexistent command on Windows"); + assert!( + result.is_none(), + "Expected None for nonexistent command on Windows" + ); #[cfg(not(windows))] - assert!(result.is_none(), "Expected None on non-Windows (always returns None)"); + assert!( + result.is_none(), + "Expected None on non-Windows (always returns None)" + ); } #[test] @@ -806,7 +835,10 @@ mod tests { #[cfg(windows)] assert!(result.is_some(), "Expected to find {} on Windows", cmd); #[cfg(not(windows))] - assert!(result.is_none(), "Expected None on non-Windows for absolute path"); + assert!( + result.is_none(), + "Expected None on non-Windows for absolute path" + ); } #[test] @@ -820,7 +852,10 @@ mod tests { fn test_resolve_command_path_finds_known_windows_binary() { // cmd.exe should always be in C:\Windows\System32 which is in PATH let result = resolve_command_path("cmd"); - assert!(result.is_some(), "Expected to find cmd.exe via PATH on Windows"); + assert!( + result.is_some(), + "Expected to find cmd.exe via PATH on Windows" + ); if let Some(p) = result { assert!( p.to_string_lossy().to_lowercase().ends_with("cmd.exe"), @@ -829,4 +864,4 @@ mod tests { ); } } -} \ No newline at end of file +} diff --git a/src/mcp/config.rs b/src/mcp/config.rs index 24b3c8e..b4c21ae 100644 --- a/src/mcp/config.rs +++ b/src/mcp/config.rs @@ -203,7 +203,11 @@ mod tests { let config = McpServerConfig::stdio( "filesystem", "npx", - vec!["-y".to_string(), "@modelcontextprotocol/server-filesystem".to_string(), "/tmp".to_string()], + vec![ + "-y".to_string(), + "@modelcontextprotocol/server-filesystem".to_string(), + "/tmp".to_string(), + ], ); assert_eq!(config.name, Some("filesystem".to_string())); @@ -267,11 +271,14 @@ mod tests { match transport { McpTransportConfig::Stdio { command, args, .. } => { assert_eq!(command, "npx"); - assert_eq!(args, vec![ - "-y", - "@modelcontextprotocol/server-filesystem", - "/home/user" - ]); + assert_eq!( + args, + vec![ + "-y", + "@modelcontextprotocol/server-filesystem", + "/home/user" + ] + ); } _ => panic!("Expected stdio transport"), } @@ -279,12 +286,18 @@ mod tests { // Check WebSearch server (streamableHttp) let websearch = config.mcp_servers.get("WebSearch").unwrap(); assert_eq!(websearch.transport_type, "streamableHttp"); - assert_eq!(websearch.name, Some("AliyunBailianMCP_WebSearch".to_string())); + assert_eq!( + websearch.name, + Some("AliyunBailianMCP_WebSearch".to_string()) + ); assert!(websearch.is_active); let transport = websearch.transport().unwrap(); match transport { McpTransportConfig::Http { url, headers } => { - assert_eq!(url, "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp"); + assert_eq!( + url, + "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp" + ); assert_eq!( headers.get("Authorization"), Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string()) @@ -385,17 +398,31 @@ mod tests { #[test] fn test_http_type_alias() { // Both "http" and "streamableHttp" should work - let json_http = r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#; + let json_http = + r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#; let json_streamable = r#"{"mcpServers": {"test": {"type": "streamableHttp", "baseUrl": "http://localhost"}}}"#; let config_http: McpConfig = serde_json::from_str(json_http).unwrap(); let config_streamable: McpConfig = serde_json::from_str(json_streamable).unwrap(); - let transport_http = config_http.mcp_servers.get("test").unwrap().transport().unwrap(); - let transport_streamable = config_streamable.mcp_servers.get("test").unwrap().transport().unwrap(); + let transport_http = config_http + .mcp_servers + .get("test") + .unwrap() + .transport() + .unwrap(); + let transport_streamable = config_streamable + .mcp_servers + .get("test") + .unwrap() + .transport() + .unwrap(); assert!(matches!(transport_http, McpTransportConfig::Http { .. })); - assert!(matches!(transport_streamable, McpTransportConfig::Http { .. })); + assert!(matches!( + transport_streamable, + McpTransportConfig::Http { .. } + )); } #[test] @@ -420,4 +447,4 @@ mod tests { let server = config.mcp_servers.get("test").unwrap(); assert!(server.cwd.is_none()); } -} \ No newline at end of file +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 95cb165..c1f70c6 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -11,10 +11,12 @@ //! //! MCP is completely optional and disabled by default. -pub mod config; pub mod client; +pub mod config; pub mod tool_adapter; +pub use client::{ + McpClient, McpClientManager, McpInitializer, McpServerInfo, McpServerStatus, McpStatusResponse, +}; pub use config::{McpConfig, McpServerConfig, McpTransportConfig}; -pub use client::{McpClientManager, McpClient, McpServerInfo, McpInitializer, McpServerStatus, McpStatusResponse}; -pub use tool_adapter::{McpToolWrapper, register_mcp_tools}; \ No newline at end of file +pub use tool_adapter::{McpToolWrapper, register_mcp_tools}; diff --git a/src/mcp/tool_adapter.rs b/src/mcp/tool_adapter.rs index e32441e..e28e587 100644 --- a/src/mcp/tool_adapter.rs +++ b/src/mcp/tool_adapter.rs @@ -25,11 +25,7 @@ pub struct McpToolWrapper { impl McpToolWrapper { /// Create a new tool wrapper - pub fn new( - manager: Arc, - server_key: String, - tool_info: Tool, - ) -> Self { + pub fn new(manager: Arc, server_key: String, tool_info: Tool) -> Self { let tool_name = tool_info.name.clone().into_owned(); let full_name = format!("mcp_{}_{}", server_key, tool_name); Self { @@ -128,11 +124,7 @@ pub async fn register_mcp_tools( let all_tools = manager.all_tools().await; for (server_key, tool_info) in all_tools { - let wrapper = McpToolWrapper::new( - manager.clone(), - server_key.clone(), - tool_info, - ); + let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info); tracing::info!( name = %wrapper.name(), @@ -153,10 +145,7 @@ mod tests { #[test] fn test_extract_text_content_from_text() { - let result = CallToolResult::success(vec![ - Content::text("Hello"), - Content::text("World"), - ]); + let result = CallToolResult::success(vec![Content::text("Hello"), Content::text("World")]); let text = extract_text_content(&result); assert_eq!(text, "Hello\nWorld"); @@ -175,10 +164,11 @@ mod tests { fn test_mcp_tool_wrapper_name() { let manager = Arc::new(McpClientManager::new()); // Create a minimal tool info using rmcp's Tool constructor - let schema: serde_json::Map = serde_json::json!({"type": "object"}) - .as_object() - .unwrap() - .clone(); + let schema: serde_json::Map = + serde_json::json!({"type": "object"}) + .as_object() + .unwrap() + .clone(); let tool_info = Tool::new("echo", "Echo tool", schema); let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info); @@ -186,4 +176,4 @@ mod tests { assert_eq!(wrapper.original_name(), "echo"); assert_eq!(wrapper.server_key(), "filesystem"); } -} \ No newline at end of file +} diff --git a/src/observability/mod.rs b/src/observability/mod.rs index 4e06af5..da04a42 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -300,7 +300,8 @@ mod tests { fn test_truncate_args_utf8_boundary() { // Test that truncation respects UTF-8 character boundaries // Each Chinese character is 3 bytes in UTF-8 - let long_args = serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"}); + let long_args = + serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"}); let truncated = truncate_args(&long_args, 50); assert!(truncated.ends_with("...truncated")); // Verify the truncated string is valid UTF-8 (no panic occurred) diff --git a/src/platform/mod.rs b/src/platform/mod.rs index f763c9e..1704872 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -151,11 +151,7 @@ pub fn is_process_waiting_on_stdin(pid: u32) -> Option { if wchan.is_empty() { return None; } - Some( - wchan.contains("tty_read") - || wchan.contains("n_tty_read") - || wchan == "pipe_wait", - ) + Some(wchan.contains("tty_read") || wchan.contains("n_tty_read") || wchan == "pipe_wait") } #[cfg(target_os = "macos")] { @@ -525,4 +521,4 @@ mod tests { assert_eq!(xml_escape("a & b"), "a & b"); assert_eq!(xml_escape(""), "<tag>"); } -} \ No newline at end of file +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index bb5a653..75812fb 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -240,9 +240,7 @@ pub enum WsOutbound { channel_name: Option, }, #[serde(rename = "channel_list")] - ChannelList { - channels: Vec, - }, + ChannelList { channels: Vec }, #[serde(rename = "topic_list")] TopicList { topics: Vec, @@ -262,7 +260,10 @@ pub enum WsOutbound { message_count: i64, }, #[serde(rename = "session_saved")] - SessionSaved { session_id: String, filepath: String }, + SessionSaved { + session_id: String, + filepath: String, + }, #[serde(rename = "task_messages_loaded")] TaskMessagesLoaded { task_id: String, @@ -273,17 +274,11 @@ pub enum WsOutbound { summary: Option, }, #[serde(rename = "scheduler_job_list")] - SchedulerJobList { - jobs: Vec, - }, + SchedulerJobList { jobs: Vec }, #[serde(rename = "memory_list")] - MemoryList { - memories: Vec, - }, + MemoryList { memories: Vec }, #[serde(rename = "skill_list")] - SkillList { - skills: Vec, - }, + SkillList { skills: Vec }, #[serde(rename = "execution_cancelled")] ExecutionCancelled { message: String }, #[serde(rename = "stream_delta")] diff --git a/src/protocol/ws_adapter.rs b/src/protocol/ws_adapter.rs index bdfe34f..7b84551 100644 --- a/src/protocol/ws_adapter.rs +++ b/src/protocol/ws_adapter.rs @@ -15,7 +15,8 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec { if let Some(tool_calls) = &message.tool_calls { let mut outbound = Vec::new(); - let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some(); + let has_content_or_reasoning = + !message.content.trim().is_empty() || message.reasoning_content.is_some(); if has_content_or_reasoning { outbound.push(WsOutbound::AssistantResponse { id: message.id.clone(), @@ -31,7 +32,11 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec Vec vec![WsOutbound::ToolResult { - id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + id: message + .tool_call_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), tool_call_id: message.tool_call_id.clone().unwrap_or_default(), tool_name: message.tool_name.clone().unwrap_or_default(), content: message.content.clone(), @@ -77,7 +85,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec vec![WsOutbound::ToolPending { - id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + id: message + .tool_call_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), tool_call_id: message.tool_call_id.clone().unwrap_or_default(), tool_name: message.tool_name.clone().unwrap_or_default(), content: message.content.clone(), @@ -107,7 +118,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve }) .collect(); vec![WsOutbound::AssistantResponse { - id: message.message_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + id: message + .message_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), content: message.content.clone(), role: message.role.clone(), attachments, @@ -176,8 +190,16 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve }], OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted { task_id: message.metadata.get("task_id").cloned().unwrap_or_default(), - description: message.metadata.get("task_description").cloned().unwrap_or_default(), - subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(), + description: message + .metadata + .get("task_description") + .cloned() + .unwrap_or_default(), + subagent_type: message + .metadata + .get("task_subagent_type") + .cloned() + .unwrap_or_default(), topic_id: message.metadata.get("topic_id").cloned(), parent_task_id: message.metadata.get("parent_task_id").cloned(), tool_call_id: message.metadata.get("tool_call_id").cloned(), diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 0ee0823..b473a83 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -41,7 +41,9 @@ fn convert_content_blocks( ) -> Vec { // 检查是否有图片且模型不支持 if !supports_images { - let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. })); + let has_images = blocks + .iter() + .any(|b| matches!(b, ContentBlock::ImageUrl { .. })); if has_images { let image_count = blocks @@ -79,10 +81,8 @@ fn convert_content_blocks( // 添加通知文本块 if !notices.is_empty() { - let notice_text = format!( - "[系统提示] 以下图片未能成功入模:\n{}", - notices.join("\n") - ); + let notice_text = + format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n")); converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text })); } @@ -182,9 +182,7 @@ impl AnthropicProvider { self.model_extra .get("supported_content_types") .and_then(|value| value.as_array()) - .map(|types| { - types.iter().any(|t| t.as_str() == Some(content_type)) - }) + .map(|types| types.iter().any(|t| t.as_str() == Some(content_type))) .unwrap_or(true) } diff --git a/src/providers/openai.rs b/src/providers/openai.rs index e411aa4..40d84ee 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -11,7 +11,11 @@ use super::traits::{StreamCallback, StreamDelta, Usage}; use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall}; use crate::domain::messages::ContentBlock; -const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["tool_call_arguments_json", "mock_response_content", "supported_content_types"]; +const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &[ + "tool_call_arguments_json", + "mock_response_content", + "supported_content_types", +]; /// 流式响应中的工具调用增量 #[derive(Debug, Default)] @@ -49,8 +53,17 @@ impl StreamingAccumulator { } /// 添加工具调用增量 - fn add_tool_call(&mut self, index: usize, id: Option<&str>, name: Option<&str>, arguments: Option<&str>) { - let entry = self.tool_calls.entry(index).or_insert_with(StreamingToolCall::default); + fn add_tool_call( + &mut self, + index: usize, + id: Option<&str>, + name: Option<&str>, + arguments: Option<&str>, + ) { + let entry = self + .tool_calls + .entry(index) + .or_insert_with(StreamingToolCall::default); // 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值 if let Some(id) = id { @@ -78,7 +91,8 @@ impl StreamingAccumulator { /// 构建最终的 ChatCompletionResponse fn build_response(self, model: String) -> ChatCompletionResponse { - let tool_calls: Vec = self.tool_calls + let tool_calls: Vec = self + .tool_calls .into_iter() .filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty()) .map(|(_, call)| { @@ -149,10 +163,13 @@ fn convert_content_blocks( ) -> Value { // 检查是否有图片且模型不支持 if !supports_images { - let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. })); + let has_images = blocks + .iter() + .any(|b| matches!(b, ContentBlock::ImageUrl { .. })); if has_images { - let image_count = blocks.iter() + let image_count = blocks + .iter() .filter(|b| matches!(b, ContentBlock::ImageUrl { .. })) .count(); @@ -186,10 +203,8 @@ fn convert_content_blocks( // 添加通知文本块 if !notices.is_empty() { - let notice_text = format!( - "[系统提示] 以下图片未能成功入模:\n{}", - notices.join("\n") - ); + let notice_text = + format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n")); converted_blocks.push(json!({ "type": "text", "text": notice_text })); } @@ -303,9 +318,7 @@ impl OpenAIProvider { self.model_extra .get("supported_content_types") .and_then(|value| value.as_array()) - .map(|types| { - types.iter().any(|t| t.as_str() == Some(content_type)) - }) + .map(|types| types.iter().any(|t| t.as_str() == Some(content_type))) .unwrap_or(true) } @@ -339,7 +352,9 @@ impl OpenAIProvider { Value::String(raw) } else { // Invalid JSON string - wrap it as a proper JSON string - Value::String(serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string())) + Value::String( + serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()), + ) } } value => Value::String( @@ -414,7 +429,7 @@ impl OpenAIProvider { // 读取 SSE 流 let mut stream = resp.bytes_stream(); let mut buffer = String::new(); - let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退 + let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退 let mut done_received = false; while let Some(chunk_result) = stream.next().await { @@ -435,7 +450,8 @@ impl OpenAIProvider { } // SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格) - let data_opt = line_trimmed.strip_prefix("data: ") + let data_opt = line_trimmed + .strip_prefix("data: ") .or_else(|| line_trimmed.strip_prefix("data:")); if let Some(data) = data_opt { @@ -459,7 +475,9 @@ impl OpenAIProvider { // 尝试从 delta 提取(标准 OpenAI 流式格式) if let Some(delta) = choice.get("delta") { // 提取内容增量 - if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { + if let Some(content) = + delta.get("content").and_then(|c| c.as_str()) + { accumulator.add_content(content); if let Some(cb) = &stream_callback { cb(StreamDelta { @@ -470,7 +488,9 @@ impl OpenAIProvider { } // 提取推理内容增量 - if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) { + if let Some(reasoning) = + delta.get("reasoning_content").and_then(|r| r.as_str()) + { accumulator.add_reasoning_content(reasoning); if let Some(cb) = &stream_callback { cb(StreamDelta { @@ -481,28 +501,43 @@ impl OpenAIProvider { } // 提取工具调用增量 - if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) { + if let Some(tool_calls) = + delta.get("tool_calls").and_then(|t| t.as_array()) + { for tool_call in tool_calls { - let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; + let index = tool_call + .get("index") + .and_then(|i| i.as_u64()) + .unwrap_or(0) + as usize; - let id = tool_call.get("id").and_then(|v| v.as_str()); - let name = tool_call.get("function") + let id = + tool_call.get("id").and_then(|v| v.as_str()); + let name = tool_call + .get("function") .and_then(|f| f.get("name")) .and_then(|n| n.as_str()); - let arguments = tool_call.get("function") + let arguments = tool_call + .get("function") .and_then(|f| f.get("arguments")) .and_then(|a| a.as_str()); - accumulator.add_tool_call(index, id, name, arguments); + accumulator + .add_tool_call(index, id, name, arguments); } } } // 尝试从 message 提取(某些非标准 API 格式) else if let Some(message) = choice.get("message") { - if let Some(content) = message.get("content").and_then(|c| c.as_str()) { + if let Some(content) = + message.get("content").and_then(|c| c.as_str()) + { accumulator.add_content(content); } - if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) { + if let Some(reasoning) = message + .get("reasoning_content") + .and_then(|r| r.as_str()) + { accumulator.add_reasoning_content(reasoning); } } @@ -533,7 +568,8 @@ impl OpenAIProvider { } // 同样支持 data: {...} 和 data:{...} 两种格式 - let data_opt = line_trimmed.strip_prefix("data: ") + let data_opt = line_trimmed + .strip_prefix("data: ") .or_else(|| line_trimmed.strip_prefix("data:")); if let Some(data) = data_opt { @@ -550,7 +586,8 @@ impl OpenAIProvider { for choice in choices { // 尝试从 delta 提取 if let Some(delta) = choice.get("delta") { - if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { + if let Some(content) = delta.get("content").and_then(|c| c.as_str()) + { accumulator.add_content(content); if let Some(cb) = &stream_callback { cb(StreamDelta { @@ -559,7 +596,9 @@ impl OpenAIProvider { }); } } - if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) { + if let Some(reasoning) = + delta.get("reasoning_content").and_then(|r| r.as_str()) + { accumulator.add_reasoning_content(reasoning); if let Some(cb) = &stream_callback { cb(StreamDelta { @@ -568,14 +607,22 @@ impl OpenAIProvider { }); } } - if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) { + if let Some(tool_calls) = + delta.get("tool_calls").and_then(|t| t.as_array()) + { for tool_call in tool_calls { - let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; + let index = tool_call + .get("index") + .and_then(|i| i.as_u64()) + .unwrap_or(0) + as usize; let id = tool_call.get("id").and_then(|v| v.as_str()); - let name = tool_call.get("function") + let name = tool_call + .get("function") .and_then(|f| f.get("name")) .and_then(|n| n.as_str()); - let arguments = tool_call.get("function") + let arguments = tool_call + .get("function") .and_then(|f| f.get("arguments")) .and_then(|a| a.as_str()); accumulator.add_tool_call(index, id, name, arguments); @@ -584,10 +631,14 @@ impl OpenAIProvider { } // 尝试从 message 提取(某些非标准 API 格式) else if let Some(message) = choice.get("message") { - if let Some(content) = message.get("content").and_then(|c| c.as_str()) { + if let Some(content) = + message.get("content").and_then(|c| c.as_str()) + { accumulator.add_content(content); } - if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) { + if let Some(reasoning) = + message.get("reasoning_content").and_then(|r| r.as_str()) + { accumulator.add_reasoning_content(reasoning); } } @@ -603,7 +654,8 @@ impl OpenAIProvider { // 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体 if response.content.is_empty() && response.tool_calls.is_empty() { if let Ok(openai_resp) = serde_json::from_str::(&raw_body) { - let fallback_content = openai_resp.choices + let fallback_content = openai_resp + .choices .first() .and_then(|c| c.message.content.as_deref()) .unwrap_or("") @@ -614,22 +666,29 @@ impl OpenAIProvider { "Streaming accumulator empty, falling back to non-SSE JSON parsing" ); response.content = fallback_content; - response.reasoning_content = openai_resp.choices + response.reasoning_content = openai_resp + .choices .first() .and_then(|c| c.message.reasoning_content.clone()); - response.tool_calls = openai_resp.choices + response.tool_calls = openai_resp + .choices .first() .map(|c| { - c.message.tool_calls.iter().map(|tc| ToolCall { - id: tc.id.clone(), - name: tc.function.name.clone(), - arguments: match &tc.function.arguments { - OAIFunctionArguments::Json(args) => args.clone(), - OAIFunctionArguments::String(args) => { - serde_json::from_str(args).unwrap_or(serde_json::Value::Null) - } - }, - }).collect() + c.message + .tool_calls + .iter() + .map(|tc| ToolCall { + id: tc.id.clone(), + name: tc.function.name.clone(), + arguments: match &tc.function.arguments { + OAIFunctionArguments::Json(args) => args.clone(), + OAIFunctionArguments::String(args) => { + serde_json::from_str(args) + .unwrap_or(serde_json::Value::Null) + } + }, + }) + .collect() }) .unwrap_or_default(); } @@ -656,9 +715,11 @@ impl OpenAIProvider { // result that precedes its parent assistant (e.g. after compaction // boundary splits), leading to API 400 errors: // "insufficient tool messages following tool_calls message". - let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut resolved_tool_ids: std::collections::HashSet<&str> = + std::collections::HashSet::new(); let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut skip_assistant_indices: std::collections::HashSet = std::collections::HashSet::new(); + let mut skip_assistant_indices: std::collections::HashSet = + std::collections::HashSet::new(); for (i, m) in request.messages.iter().enumerate().rev() { if m.role == "tool" { @@ -670,8 +731,9 @@ impl OpenAIProvider { if m.role == "assistant" { if let Some(ref calls) = m.tool_calls { if !calls.is_empty() { - let all_resolved = - calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str())); + let all_resolved = calls + .iter() + .all(|tc| resolved_tool_ids.contains(tc.id.as_str())); if all_resolved { for tc in calls { with_parent.insert(tc.id.as_str()); @@ -695,7 +757,8 @@ impl OpenAIProvider { // ^ reverse scan sees tool(A) after assistant → "resolved" // but API requires tool(A) to be IMMEDIATELY after assistant { - let mut pending_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut pending_tool_ids: std::collections::HashSet<&str> = + std::collections::HashSet::new(); let mut pending_assistant_idx: Option = None; for (i, m) in request.messages.iter().enumerate() { @@ -892,30 +955,38 @@ impl OpenAIProvider { /// avoid flooding logs on every request — see callers in `chat` and /// `chat_streaming_internal`. fn format_message_sequence(body: &Value) -> Vec { - body["messages"].as_array() - .map(|msgs| msgs.iter().enumerate().map(|(i, m)| { - let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?"); - match role { - "assistant" => { - let tc_count = m.get("tool_calls") - .and_then(|t| t.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - if tc_count > 0 { - format!("[{}] assistant(tool_calls={})", i, tc_count) - } else { - format!("[{}] assistant", i) + body["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .enumerate() + .map(|(i, m)| { + let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?"); + match role { + "assistant" => { + let tc_count = m + .get("tool_calls") + .and_then(|t| t.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + if tc_count > 0 { + format!("[{}] assistant(tool_calls={})", i, tc_count) + } else { + format!("[{}] assistant", i) + } + } + "tool" => { + let tcid = m + .get("tool_call_id") + .and_then(|t| t.as_str()) + .unwrap_or("??"); + format!("[{}] tool(id={})", i, tcid) + } + _ => format!("[{}] {}", i, role), } - } - "tool" => { - let tcid = m.get("tool_call_id") - .and_then(|t| t.as_str()) - .unwrap_or("??"); - format!("[{}] tool(id={})", i, tcid) - } - _ => format!("[{}] {}", i, role), - } - }).collect()) + }) + .collect() + }) .unwrap_or_default() } @@ -1141,7 +1212,10 @@ impl LLMProvider for OpenAIProvider { callback: StreamCallback, ) -> Result> { if self.is_streaming_enabled() { - match self.chat_streaming_internal(&request, Some(&callback)).await { + match self + .chat_streaming_internal(&request, Some(&callback)) + .await + { Ok(response) => return Ok(response), Err(e) => { tracing::debug!( @@ -1471,7 +1545,12 @@ mod tests { let mut accumulator = StreamingAccumulator::new(); // 第一个 chunk:包含完整的 id 和 name - accumulator.add_tool_call(0, Some("call_abc123"), Some("memory_search"), Some("{\"action\":\"")); + accumulator.add_tool_call( + 0, + Some("call_abc123"), + Some("memory_search"), + Some("{\"action\":\""), + ); // 第二个 chunk:只有参数增量 accumulator.add_tool_call(0, None, None, Some("list")); // 第三个 chunk:参数继续 @@ -1487,7 +1566,10 @@ mod tests { assert_eq!(response.tool_calls.len(), 1); assert_eq!(response.tool_calls[0].id, "call_abc123"); assert_eq!(response.tool_calls[0].name, "memory_search"); - assert_eq!(response.tool_calls[0].arguments, json!({"action":"list", "limit": 20})); + assert_eq!( + response.tool_calls[0].arguments, + json!({"action":"list", "limit": 20}) + ); } #[test] @@ -1495,7 +1577,12 @@ mod tests { let mut accumulator = StreamingAccumulator::new(); // 第一个工具调用 - accumulator.add_tool_call(0, Some("call_1"), Some("calculator"), Some("{\"expr\": \"1+1\"}")); + accumulator.add_tool_call( + 0, + Some("call_1"), + Some("calculator"), + Some("{\"expr\": \"1+1\"}"), + ); // 第二个工具调用(id 和 name 只在第一个 chunk 出现) accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}")); @@ -1600,7 +1687,10 @@ mod tests { "supported_content_types".to_string(), Value::Array(vec![Value::String("text".to_string())]), ), - ("custom_param".to_string(), Value::String("value".to_string())), + ( + "custom_param".to_string(), + Value::String("value".to_string()), + ), ]), ); @@ -1737,7 +1827,8 @@ mod tests { let messages = body["messages"].as_array().unwrap(); // Assistant should keep tool_calls (valid immediate sequence) - let tool_calls = messages[0].get("tool_calls") + let tool_calls = messages[0] + .get("tool_calls") .and_then(|t| t.as_array()) .expect("tool_calls should be preserved when immediately followed"); assert_eq!(tool_calls.len(), 1); diff --git a/src/providers/traits.rs b/src/providers/traits.rs index caa0c8e..223ca1d 100644 --- a/src/providers/traits.rs +++ b/src/providers/traits.rs @@ -1,6 +1,6 @@ +use crate::config::LLMProviderConfig; use crate::domain::messages::{ContentBlock, ToolCall}; use crate::domain::tools::Tool; -use crate::config::LLMProviderConfig; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 6f08ae9..5c46f03 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -59,7 +59,9 @@ pub trait AgentTaskExecutor: Send + Sync { pub trait MaintenanceExecutor: Send + Sync { async fn cleanup_expired_sessions(&self) -> usize; - async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result>; + async fn run_memory_maintenance_for_all_scopes( + &self, + ) -> anyhow::Result>; } pub struct Scheduler { @@ -452,11 +454,15 @@ fn scheduler_job_definition_matches( existing: &SchedulerJobRecord, ) -> bool { let input_schedule = serde_json::from_value::(input.schedule.clone()).ok(); - let existing_schedule = - deserialize_schedule(&existing.schedule, existing.interval_secs, existing.startup_delay_secs) - .ok(); + let existing_schedule = deserialize_schedule( + &existing.schedule, + existing.interval_secs, + existing.startup_delay_secs, + ) + .ok(); let input_target = serde_json::from_value::(input.target.clone()).ok(); - let existing_target = serde_json::from_value::(existing.target.clone()).ok(); + let existing_target = + serde_json::from_value::(existing.target.clone()).ok(); let targets_match = match (input_target, existing_target) { (Some(input_target), Some(existing_target)) => { input_target.channel == existing_target.channel @@ -813,7 +819,10 @@ fn convert_weekday_field(expression: &str) -> String { let weekday_field = parts[5]; let converted = convert_cron_weekday(weekday_field); - format!("{} {} {} {} {} {}", parts[0], parts[1], parts[2], parts[3], parts[4], converted) + format!( + "{} {} {} {} {} {}", + parts[0], parts[1], parts[2], parts[3], parts[4], converted + ) } /// 转换星期表达式中的数字 @@ -824,9 +833,10 @@ fn convert_cron_weekday(field: &str) -> String { // 处理列表(逗号分隔) let items: Vec<&str> = field.split(',').collect(); - let converted_items: Vec = items.iter().map(|item| { - convert_weekday_item(item.trim()) - }).collect(); + let converted_items: Vec = items + .iter() + .map(|item| convert_weekday_item(item.trim())) + .collect(); converted_items.join(",") } @@ -865,14 +875,14 @@ fn convert_weekday_range_or_value(item: &str) -> String { /// 转换单个星期数字 fn convert_single_weekday(day: &str) -> String { match day { - "0" | "7" => "1".to_string(), // 周日 -> 1 - "1" => "2".to_string(), // 周一 -> 2 - "2" => "3".to_string(), // 周二 -> 3 - "3" => "4".to_string(), // 周三 -> 4 - "4" => "5".to_string(), // 周四 -> 5 - "5" => "6".to_string(), // 周五 -> 6 - "6" => "7".to_string(), // 周六 -> 7 - _ => day.to_string(), // 其他(如字母)保持不变 + "0" | "7" => "1".to_string(), // 周日 -> 1 + "1" => "2".to_string(), // 周一 -> 2 + "2" => "3".to_string(), // 周二 -> 3 + "3" => "4".to_string(), // 周三 -> 4 + "4" => "5".to_string(), // 周四 -> 5 + "5" => "6".to_string(), // 周五 -> 6 + "6" => "7".to_string(), // 周六 -> 7 + _ => day.to_string(), // 其他(如字母)保持不变 } } @@ -929,7 +939,9 @@ async fn execute_internal_event( Ok(()) } "memory_maintenance" => { - let results = maintenance_executor.run_memory_maintenance_for_all_scopes().await?; + let results = maintenance_executor + .run_memory_maintenance_for_all_scopes() + .await?; for result in &results { tracing::info!( job_id = %job.id, @@ -1284,10 +1296,10 @@ impl TryFrom for SchedulerJobTarget { #[cfg(test)] mod tests { use super::*; - use chrono::{Datelike, Timelike}; use crate::bus::MessageBus; use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID; use crate::storage::{SchedulerJobUpsert, SessionStore}; + use chrono::{Datelike, Timelike}; #[derive(Clone)] struct TestAgentTaskExecutor; @@ -1325,7 +1337,9 @@ mod tests { 0 } - async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result> { + async fn run_memory_maintenance_for_all_scopes( + &self, + ) -> anyhow::Result> { Ok(Vec::new()) } } @@ -1592,17 +1606,19 @@ mod tests { let probe_runtime = RuntimeJob::from_config( &config_job, - Utc.timestamp_millis_opt(1_700_000_000_000).single().unwrap(), + Utc.timestamp_millis_opt(1_700_000_000_000) + .single() + .unwrap(), SchedulerMisfirePolicy::Skip, chrono_tz::Asia::Shanghai, ) .unwrap(); - let probe_existing = store - .get_scheduler_job("agent.heartbeat") - .unwrap() - .unwrap(); + let probe_existing = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap(); let probe_upsert = probe_runtime.to_upsert(); - assert!(scheduler_job_definition_matches(&probe_upsert, &probe_existing)); + assert!(scheduler_job_definition_matches( + &probe_upsert, + &probe_existing + )); let (agent_task_executor, maintenance_service) = test_scheduler_services(); let scheduler = Scheduler::new( @@ -1622,10 +1638,7 @@ mod tests { scheduler.sync_config_jobs().unwrap(); - let saved = store - .get_scheduler_job("agent.heartbeat") - .unwrap() - .unwrap(); + let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap(); assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at)); assert_eq!(saved.run_count, 3); @@ -1723,7 +1736,6 @@ mod tests { ); } - #[test] fn debug_cron_weekday_definitions() { // 重大发现:cron crate 的星期定义是反常规的! @@ -1742,9 +1754,16 @@ mod tests { ]; // 从周六(2026-04-25)开始测试 - let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap(); + let saturday = Utc + .with_ymd_and_hms(2026, 4, 25, 10, 0, 0) + .single() + .unwrap(); let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai); - println!("\n=== 从周六 {} ({:?}) 开始测试 ===", shanghai_saturday, shanghai_saturday.weekday()); + println!( + "\n=== 从周六 {} ({:?}) 开始测试 ===", + shanghai_saturday, + shanghai_saturday.weekday() + ); for (expr, desc) in &test_cases { let schedule = parse_scheduler_cron(expr).unwrap(); @@ -1757,21 +1776,49 @@ mod tests { let schedule_workday = parse_scheduler_cron("0 9 * * 1-5").unwrap(); let sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap(); - println!("周六 -> 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday()); - assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周六跳到周一"); + println!( + "周六 -> 1-5 下次执行: {} (星期: {:?})", + sat_next, + sat_next.weekday() + ); + assert_eq!( + sat_next.weekday(), + chrono::Weekday::Mon, + "1-5 应该从周六跳到周一" + ); // 从周日开始 - let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap(); + let sunday = Utc + .with_ymd_and_hms(2026, 4, 26, 10, 0, 0) + .single() + .unwrap(); let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai); let sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap(); - println!("周日 -> 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday()); - assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周日跳到周一"); + println!( + "周日 -> 1-5 下次执行: {} (星期: {:?})", + sun_next, + sun_next.weekday() + ); + assert_eq!( + sun_next.weekday(), + chrono::Weekday::Mon, + "1-5 应该从周日跳到周一" + ); // 从周一早上7点开始 - let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap(); - println!("周一早上7点 -> 1-5 下次执行: {} (星期: {:?})", + let shanghai_monday = chrono_tz::Asia::Shanghai + .with_ymd_and_hms(2026, 4, 27, 7, 0, 0) + .single() + .unwrap(); + println!( + "周一早上7点 -> 1-5 下次执行: {} (星期: {:?})", schedule_workday.after(&shanghai_monday).next().unwrap(), - schedule_workday.after(&shanghai_monday).next().unwrap().weekday()); + schedule_workday + .after(&shanghai_monday) + .next() + .unwrap() + .weekday() + ); } /// 测试标准 cron 星期转换功能 @@ -1784,62 +1831,103 @@ mod tests { #[test] fn standard_cron_weekday_conversion() { // 测试:标准 cron 的 1-5 应该表示周一到周五 - let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap(); + let saturday = Utc + .with_ymd_and_hms(2026, 4, 25, 10, 0, 0) + .single() + .unwrap(); let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai); // 现在使用标准 cron:1-5 表示周一到周五 let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap(); let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap(); - println!("周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday()); - assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周六跳到周一"); + println!( + "周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", + sat_next, + sat_next.weekday() + ); + assert_eq!( + sat_next.weekday(), + chrono::Weekday::Mon, + "标准 cron 1-5 应该从周六跳到周一" + ); // 从周日开始 - let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap(); + let sunday = Utc + .with_ymd_and_hms(2026, 4, 26, 10, 0, 0) + .single() + .unwrap(); let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai); let sun_next = schedule_std.after(&shanghai_sunday).next().unwrap(); - println!("周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday()); - assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周日跳到周一"); + println!( + "周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", + sun_next, + sun_next.weekday() + ); + assert_eq!( + sun_next.weekday(), + chrono::Weekday::Mon, + "标准 cron 1-5 应该从周日跳到周一" + ); // 从周一开始(上海时间周一早上7点) - let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap(); + let shanghai_monday = chrono_tz::Asia::Shanghai + .with_ymd_and_hms(2026, 4, 27, 7, 0, 0) + .single() + .unwrap(); let mon_next = schedule_std.after(&shanghai_monday).next().unwrap(); - println!("周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", mon_next, mon_next.weekday()); - assert_eq!(mon_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该当天执行"); + println!( + "周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", + mon_next, + mon_next.weekday() + ); + assert_eq!( + mon_next.weekday(), + chrono::Weekday::Mon, + "标准 cron 1-5 应该当天执行" + ); assert_eq!(mon_next.hour(), 9, "应该是上海时间9点"); // 从周五开始(应该下周周一) let friday = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).single().unwrap(); // 周五 let shanghai_friday = friday.with_timezone(&chrono_tz::Asia::Shanghai); let fri_next = schedule_std.after(&shanghai_friday).next().unwrap(); - println!("周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", fri_next, fri_next.weekday()); - assert_eq!(fri_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周五跳到下周一"); + println!( + "周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", + fri_next, + fri_next.weekday() + ); + assert_eq!( + fri_next.weekday(), + chrono::Weekday::Mon, + "标准 cron 1-5 应该从周五跳到下周一" + ); } /// 测试转换辅助函数 #[test] fn test_weekday_conversion_helper() { // 测试单个值 - assert_eq!(convert_single_weekday("0"), "1"); // 周日 - assert_eq!(convert_single_weekday("1"), "2"); // 周一 - assert_eq!(convert_single_weekday("5"), "6"); // 周五 - assert_eq!(convert_single_weekday("6"), "7"); // 周六 - assert_eq!(convert_single_weekday("7"), "1"); // 周日(标准 cron 兼容写法) + assert_eq!(convert_single_weekday("0"), "1"); // 周日 + assert_eq!(convert_single_weekday("1"), "2"); // 周一 + assert_eq!(convert_single_weekday("5"), "6"); // 周五 + assert_eq!(convert_single_weekday("6"), "7"); // 周六 + assert_eq!(convert_single_weekday("7"), "1"); // 周日(标准 cron 兼容写法) // 测试范围 - assert_eq!(convert_weekday_range_or_value("1-5"), "2-6"); // 周一到周五 - assert_eq!(convert_weekday_range_or_value("0-6"), "1-7"); // 周日到周六 - assert_eq!(convert_weekday_range_or_value("0-7"), "1-1"); // 周日(循环) + assert_eq!(convert_weekday_range_or_value("1-5"), "2-6"); // 周一到周五 + assert_eq!(convert_weekday_range_or_value("0-6"), "1-7"); // 周日到周六 + assert_eq!(convert_weekday_range_or_value("0-7"), "1-1"); // 周日(循环) // 测试列表 - assert_eq!(convert_cron_weekday("1,3,5"), "2,4,6"); // 周一、三、五 - assert_eq!(convert_cron_weekday("0,6"), "1,7"); // 周日和周六 + assert_eq!(convert_cron_weekday("1,3,5"), "2,4,6"); // 周一、三、五 + assert_eq!(convert_cron_weekday("0,6"), "1,7"); // 周日和周六 // 测试步长 - assert_eq!(convert_weekday_item("*/2"), "*/2"); // 步长保持不变 + assert_eq!(convert_weekday_item("*/2"), "*/2"); // 步长保持不变 // 测试混合 - assert_eq!(convert_cron_weekday("1-5,7"), "2-6,1"); // 周一到周五 + 周日 + assert_eq!(convert_cron_weekday("1-5,7"), "2-6,1"); // 周一到周五 + 周日 // 测试特殊字符 assert_eq!(convert_cron_weekday("*"), "*"); diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 2d7ddaa..09982cb 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -1,4 +1,6 @@ -use crate::platform::{atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape}; +use crate::platform::{ + atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape, +}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{HashMap, HashSet}; @@ -11,7 +13,9 @@ static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[cfg(test)] pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> { - SKILL_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) + SKILL_TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()) } use crate::config::SkillsConfig; @@ -209,16 +213,23 @@ impl SkillRuntime { let catalog = SkillCatalog::discover_without_state(&self.config, &cwd); let disable_state = load_skill_disable_state(&cwd); - catalog.skills.iter().map(|skill| { - let disabled_scopes = disable_state.disabled_scopes_for(&skill.name); - SkillWithStatus { - name: skill.name.clone(), - description: skill.description.clone(), - source: skill.source.as_str().to_string(), - path: skill.path.display().to_string(), - disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(), - } - }).collect() + catalog + .skills + .iter() + .map(|skill| { + let disabled_scopes = disable_state.disabled_scopes_for(&skill.name); + SkillWithStatus { + name: skill.name.clone(), + description: skill.description.clone(), + source: skill.source.as_str().to_string(), + path: skill.path.display().to_string(), + disabled_in_scopes: disabled_scopes + .iter() + .map(|s| s.as_str().to_string()) + .collect(), + } + }) + .collect() } pub fn get_skill(&self, name: &str) -> Option { @@ -321,8 +332,8 @@ impl SkillRuntime { pub fn has_skill_definition(&self, name: &str) -> Result { validate_skill_name(name)?; - let cwd = std::env::current_dir() - .map_err(|err| format!("failed to get current dir: {}", err))?; + let cwd = + std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?; Ok(SkillCatalog::discover_without_state(&self.config, &cwd) .find_skill(name) .is_some()) @@ -358,8 +369,8 @@ impl SkillRuntime { let _ = self.reload()?; } - let cwd = std::env::current_dir() - .map_err(|err| format!("failed to get current dir: {}", err))?; + let cwd = + std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?; let effective_state = load_skill_disable_state(&cwd); let disabled_in_scopes = effective_state.disabled_scopes_for(name); @@ -756,8 +767,9 @@ fn skill_file_path(scope: SkillScope, name: &str) -> Result { fn skill_state_path(scope: SkillScope) -> Result { match scope { - SkillScope::User => user_skill_state_path() - .ok_or_else(|| "failed to resolve home directory".to_string()), + SkillScope::User => { + user_skill_state_path().ok_or_else(|| "failed to resolve home directory".to_string()) + } SkillScope::Project => { let cwd = std::env::current_dir() .map_err(|err| format!("failed to get current dir: {}", err))?; @@ -966,10 +978,7 @@ impl SystemPromptProvider for SkillPromptProvider { // 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认) let content = match context.session_id.as_deref() { Some(sid) => { - let policy = self - .experts - .selected_expert_for(sid) - .map(|e| e.capability); + let policy = self.experts.selected_expert_for(sid).map(|e| e.capability); match policy { Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered( p.allowed_skills.as_deref(), @@ -1058,7 +1067,11 @@ mod tests { let skill_dir = dir.path().join("demo"); fs::create_dir_all(&skill_dir).unwrap(); let skill_md = skill_dir.join("SKILL.md"); - fs::write(&skill_md, "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B").unwrap(); + fs::write( + &skill_md, + "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B", + ) + .unwrap(); let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap(); assert_eq!(skill.name, "demo"); @@ -1128,7 +1141,10 @@ mod tests { // 验证 location 包含正确的 file:// URI 格式 let expected_uri = path_to_uri(&skill_path); - assert!(prompt.contains(&format!("{}", platform_xml_escape(&expected_uri)))); + assert!(prompt.contains(&format!( + "{}", + platform_xml_escape(&expected_uri) + ))); assert!(prompt.contains("")); } @@ -1388,13 +1404,17 @@ mod tests { max_listed_skills: 32, }); - let disabled = runtime.disable_skill(SkillScope::Project, "demo", true).unwrap(); + let disabled = runtime + .disable_skill(SkillScope::Project, "demo", true) + .unwrap(); assert!(disabled.changed); assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]); assert!(!disabled.available); assert!(runtime.get_skill("demo").is_none()); - let enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap(); + let enabled = runtime + .enable_skill(SkillScope::Project, "demo", true) + .unwrap(); assert!(enabled.changed); assert!(enabled.disabled_in_scopes.is_empty()); assert!(enabled.available); @@ -1427,16 +1447,22 @@ mod tests { max_listed_skills: 32, }); - let user_disabled = runtime.disable_skill(SkillScope::User, "demo", true).unwrap(); + let user_disabled = runtime + .disable_skill(SkillScope::User, "demo", true) + .unwrap(); assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]); assert!(runtime.get_skill("demo").is_none()); - let project_enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap(); + let project_enabled = runtime + .enable_skill(SkillScope::Project, "demo", true) + .unwrap(); assert!(!project_enabled.available); assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]); assert!(runtime.get_skill("demo").is_none()); - let user_enabled = runtime.enable_skill(SkillScope::User, "demo", true).unwrap(); + let user_enabled = runtime + .enable_skill(SkillScope::User, "demo", true) + .unwrap(); assert!(user_enabled.available); assert!(user_enabled.disabled_in_scopes.is_empty()); assert!(runtime.get_skill("demo").is_some()); @@ -1506,7 +1532,9 @@ mod tests { }); assert_eq!(catalog.len(), 1); - let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap(); + let payload = catalog + .activation_event_payload("demo-user-openclaw") + .unwrap(); assert_eq!(payload["source"], "user_openclaw"); } @@ -1565,7 +1593,9 @@ mod tests { ); // After enabling, list_skills_with_status should report no disabled scopes - runtime.enable_skill(SkillScope::Project, "demo", true).unwrap(); + runtime + .enable_skill(SkillScope::Project, "demo", true) + .unwrap(); let skills_after = runtime.list_skills_with_status(); assert_eq!(skills_after.len(), 1); assert!(skills_after[0].disabled_in_scopes.is_empty()); diff --git a/src/storage/mod.rs b/src/storage/mod.rs index ead4bbe..493675c 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -24,10 +24,10 @@ pub use ports::{ SkillEventRepository, TodoRepository, }; pub use records::{ - allowed_namespace_names, get_namespace_description, is_valid_namespace, ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord, - TodoRecord, TopicRecord, + TodoRecord, TopicRecord, allowed_namespace_names, get_namespace_description, + is_valid_namespace, }; #[derive(Clone)] @@ -228,14 +228,11 @@ impl SessionStore { drop(conn); - let manager = SqliteConnectionManager::file(db_uri) - .with_init(|c| { - c.busy_timeout(std::time::Duration::from_secs(30))?; - Ok(()) - }); - let pool = Pool::builder() - .max_size(8) - .build(manager)?; + let manager = SqliteConnectionManager::file(db_uri).with_init(|c| { + c.busy_timeout(std::time::Duration::from_secs(30))?; + Ok(()) + }); + let pool = Pool::builder().max_size(8).build(manager)?; Ok(Self { pool }) } @@ -245,8 +242,7 @@ impl SessionStore { // Use a temp file so the database survives across pool connections. // Temp dir is cleaned by the OS eventually; tests that need cleanup // can call std::fs::remove_file on the path. - let path = std::env::temp_dir() - .join(format!("picobot_test_{}.db", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!("picobot_test_{}.db", uuid::Uuid::new_v4())); let conn = Connection::open(&path)?; let path_str = path.to_string_lossy().to_string(); // ignore unused mut warning for manager in tests @@ -304,7 +300,12 @@ impl SessionStore { chat_id: &str, ) -> Result { let session_id = persistent_session_id(channel_name, chat_id); - self.ensure_session(&session_id, channel_name, chat_id, &format!("{}:{}", channel_name, chat_id)) + self.ensure_session( + &session_id, + channel_name, + chat_id, + &format!("{}:{}", channel_name, chat_id), + ) } /// 确保指定 session_id 的会话存在(如果不存在则创建) @@ -512,7 +513,11 @@ impl SessionStore { Ok(()) } - pub fn update_topic_description(&self, topic_id: &str, description: &str) -> Result<(), StorageError> { + pub fn update_topic_description( + &self, + topic_id: &str, + description: &str, + ) -> Result<(), StorageError> { let now = current_timestamp(); let conn = self.pool.get()?; conn.execute( @@ -810,12 +815,7 @@ impl SessionStore { archived_at = NULL WHERE id = ?1 AND deleted_at IS NULL ", - params![ - session_id, - inserted_count, - active_user_turn_count, - now, - ], + params![session_id, inserted_count, active_user_turn_count, now,], )?; tx.commit()?; @@ -1583,7 +1583,8 @@ impl SessionStore { /// 获取指定话题的消息数量(动态计算,确保准确) pub fn get_topic_message_count(&self, topic_id: &str) -> Result { - self.load_messages_for_topic(topic_id, None).map(|msgs| msgs.len()) + self.load_messages_for_topic(topic_id, None) + .map(|msgs| msgs.len()) } pub fn load_all_messages(&self, session_id: &str) -> Result, StorageError> { @@ -1619,10 +1620,7 @@ impl SessionStore { let now = current_timestamp(); // Delete existing todos for this scope_key - tx.execute( - "DELETE FROM todos WHERE scope_key = ?1", - params![scope_key], - )?; + tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?; // Insert new todos for item in items { @@ -1669,7 +1667,7 @@ impl SessionStore { for row in rows { result.push(row?); } - drop(stmt); // 释放 stmt 借用,才能 commit + drop(stmt); // 释放 stmt 借用,才能 commit tx.commit()?; Ok(result) } @@ -1950,7 +1948,7 @@ fn load_messages_after( messages.push(row?); } Ok(messages) - } +} fn current_timestamp() -> i64 { std::time::SystemTime::now() diff --git a/src/storage/records.rs b/src/storage/records.rs index 5bf5a02..90127a1 100644 --- a/src/storage/records.rs +++ b/src/storage/records.rs @@ -8,18 +8,38 @@ pub const GLOBAL_SCOPE_KEY: &str = "default"; /// 每个命名空间代表一类记忆内容,用于分类管理和检索。 /// 禁止使用未在此列表中的 namespace 创建记忆。 pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[ - ("user", "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作"), - ("semantic", "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累"), - ("episodic", "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪"), - ("skill", "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行"), - ("environment", "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力"), - ("reflection", "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进"), + ( + "user", + "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作", + ), + ( + "semantic", + "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累", + ), + ( + "episodic", + "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪", + ), + ( + "skill", + "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行", + ), + ( + "environment", + "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力", + ), + ( + "reflection", + "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进", + ), ("other", "其他记忆:不属于以上分类的其他记忆内容"), ]; /// 验证 namespace 是否在允许列表中 pub fn is_valid_namespace(namespace: &str) -> bool { - ALLOWED_MEMORY_NAMESPACES.iter().any(|(name, _)| *name == namespace) + ALLOWED_MEMORY_NAMESPACES + .iter() + .any(|(name, _)| *name == namespace) } /// 获取 namespace 的中文描述 @@ -32,7 +52,10 @@ pub fn get_namespace_description(namespace: &str) -> Option<&'static str> { /// 获取所有允许的 namespace 名称列表(用于 JSON schema enum) pub fn allowed_namespace_names() -> Vec<&'static str> { - ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect() + ALLOWED_MEMORY_NAMESPACES + .iter() + .map(|(name, _)| *name) + .collect() } #[derive(Debug, Clone)] diff --git a/src/storage/row_mapping.rs b/src/storage/row_mapping.rs index 3f86357..4747920 100644 --- a/src/storage/row_mapping.rs +++ b/src/storage/row_mapping.rs @@ -97,7 +97,9 @@ pub(super) fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { +pub(super) fn map_skill_event_record( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { let payload_json: String = row.get(4)?; let payload = serde_json::from_str(&payload_json).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) @@ -129,11 +131,7 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result< .map(serde_json::from_str) .transpose() .map_err(|err| { - rusqlite::Error::FromSqlConversionFailure( - 9, - rusqlite::types::Type::Text, - Box::new(err), - ) + rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(err)) })?; Ok(ChatMessage { diff --git a/src/storage/tests.rs b/src/storage/tests.rs index cf4ee50..1cefb3f 100644 --- a/src/storage/tests.rs +++ b/src/storage/tests.rs @@ -1,5 +1,5 @@ -use super::*; use super::migrations::has_column; +use super::*; use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT; use crate::domain::messages::ToolCall; @@ -10,10 +10,19 @@ fn test_persistent_session_id_for_cli_and_channel() { assert_eq!(persistent_session_id("cli", "abc"), "abc"); // 幂等:已带前缀的 chat_id 会被清理,不会累积前缀 assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc"); - assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc"); - assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc"); + assert_eq!( + persistent_session_id("websocket", "websocket:websocket:abc"), + "abc" + ); + assert_eq!( + persistent_session_id(TEST_CHANNEL, "abc"), + "test-channel:abc" + ); // 其他通道也幂等 - assert_eq!(persistent_session_id(TEST_CHANNEL, "test-channel:abc"), "test-channel:abc"); + assert_eq!( + persistent_session_id(TEST_CHANNEL, "test-channel:abc"), + "test-channel:abc" + ); } #[test] @@ -76,8 +85,12 @@ fn test_session_store_roundtrip_and_lifecycle() { fn test_ensure_channel_session_is_stable() { let store = SessionStore::in_memory().unwrap(); - let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); - let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); + let first = store + .ensure_channel_session(TEST_CHANNEL, "chat-1") + .unwrap(); + let second = store + .ensure_channel_session(TEST_CHANNEL, "chat-1") + .unwrap(); assert_eq!(first.id, second.id); assert_eq!(first.chat_id, "chat-1"); @@ -176,8 +189,7 @@ fn test_schema_migration_adds_user_turn_and_reinjection_columns() { #[test] fn test_schema_migration_adds_reasoning_content_column_to_messages() { - let tmp = std::env::temp_dir() - .join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4())); + let tmp = std::env::temp_dir().join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4())); let conn = Connection::open(&tmp).unwrap(); conn.execute_batch( " @@ -225,10 +237,8 @@ fn test_compact_active_history_rebuilds_active_segment_with_delta_messages() { let store = SessionStore::in_memory().unwrap(); let session = store.create_cli_session(Some("compact-history")).unwrap(); - let agent_prompt = ChatMessage::system_with_context( - "agent", - Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), - ); + let agent_prompt = + ChatMessage::system_with_context("agent", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string())); let seed_messages = vec![ agent_prompt.clone(), ChatMessage::user("u1"), @@ -378,7 +388,10 @@ fn test_memory_roundtrip_with_source_fields() { assert_eq!(saved.content, "Rust"); assert_eq!(saved.source_type, "message"); - assert_eq!(saved.source_session_id.as_deref(), Some("test-channel:chat-1")); + assert_eq!( + saved.source_session_id.as_deref(), + Some("test-channel:chat-1") + ); assert_eq!(saved.source_message_id.as_deref(), Some("msg-1")); assert_eq!(saved.source_message_seq, Some(7)); @@ -474,7 +487,13 @@ fn test_memory_search_matches_memory_key_field() { .unwrap(); let hits = store - .search_memories("user", "test-channel:user-1", "email_folder_preference", None, 10) + .search_memories( + "user", + "test-channel:user-1", + "email_folder_preference", + None, + 10, + ) .unwrap(); assert_eq!(hits.len(), 1); @@ -585,7 +604,10 @@ fn test_memory_scope_listing_and_full_scope_read() { let scope_keys = store.list_memory_scope_keys("user").unwrap(); assert_eq!( scope_keys, - vec!["test-channel:user-1".to_string(), "test-channel:user-2".to_string()] + vec![ + "test-channel:user-1".to_string(), + "test-channel:user-2".to_string() + ] ); let full_scope = store diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 73e1837..d9e6236 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -13,7 +13,7 @@ use tokio::time::{Instant, sleep_until}; use crate::platform::{ShellInfo, dangerous_command_patterns}; use crate::tools::shell_session::ShellSessionManager; use crate::tools::traits::{Tool, ToolResult}; -use crate::tools::{extract_u64, extract_bool, check_null_args}; +use crate::tools::{check_null_args, extract_bool, extract_u64}; const MAX_TIMEOUT_SECS: u64 = 600; const MAX_OUTPUT_CHARS: usize = 50_000; @@ -84,7 +84,11 @@ impl ShellKind { /// 执行命令所需的参数 pub fn command_args<'a>(&self, command: &'a str) -> Vec<&'a str> { let info = self.to_info(); - info.args.iter().map(|s| *s).chain(std::iter::once(command)).collect() + info.args + .iter() + .map(|s| *s) + .chain(std::iter::once(command)) + .collect() } /// 工具名称 @@ -95,9 +99,15 @@ impl ShellKind { /// 工具描述 pub fn tool_description(&self) -> &'static str { match self { - ShellKind::Bash => "Execute a bash shell command and return its output. Use with caution.", - ShellKind::PowerShell => "Execute a PowerShell command and return its output. Use with caution.", - ShellKind::Cmd => "Execute a cmd shell command and return its output. Use with caution.", + ShellKind::Bash => { + "Execute a bash shell command and return its output. Use with caution." + } + ShellKind::PowerShell => { + "Execute a PowerShell command and return its output. Use with caution." + } + ShellKind::Cmd => { + "Execute a cmd shell command and return its output. Use with caution." + } } } } @@ -189,10 +199,7 @@ impl BashTool { }; format!( "{}\n{}{}\n\n{}", - PENDING_USER_ACTION_MARKER, - session_line, - hint, - output_section + PENDING_USER_ACTION_MARKER, session_line, hint, output_section ) } @@ -711,10 +718,7 @@ mod tests { } else { "echo 'Hello World'" }; - let result = tool - .execute(json!({ "command": command })) - .await - .unwrap(); + let result = tool.execute(json!({ "command": command })).await.unwrap(); assert!(result.success); assert!(result.output.contains("Hello World")); @@ -742,10 +746,7 @@ mod tests { } else { format!("ls -la {}", temp_dir.display()) }; - let result = tool - .execute(json!({ "command": command })) - .await - .unwrap(); + let result = tool.execute(json!({ "command": command })).await.unwrap(); assert!(result.success); } @@ -892,8 +893,17 @@ mod tests { #[test] fn test_shell_kind_command_args() { - assert_eq!(ShellKind::Bash.command_args("echo hello"), vec!["-c" as &str, "echo hello"]); - assert_eq!(ShellKind::PowerShell.command_args("echo hello"), vec!["-Command" as &str, "echo hello"]); - assert_eq!(ShellKind::Cmd.command_args("echo hello"), vec!["/C" as &str, "echo hello"]); + assert_eq!( + ShellKind::Bash.command_args("echo hello"), + vec!["-c" as &str, "echo hello"] + ); + assert_eq!( + ShellKind::PowerShell.command_args("echo hello"), + vec!["-Command" as &str, "echo hello"] + ); + assert_eq!( + ShellKind::Cmd.command_args("echo hello"), + vec!["/C" as &str, "echo hello"] + ); } } diff --git a/src/tools/calculator.rs b/src/tools/calculator.rs index 647f189..90d43dd 100644 --- a/src/tools/calculator.rs +++ b/src/tools/calculator.rs @@ -1,6 +1,6 @@ use super::traits::{Tool, ToolResult}; -use crate::tools::extract_f64 as extract_f64_opt; use crate::tools::check_null_args; +use crate::tools::extract_f64 as extract_f64_opt; use async_trait::async_trait; use serde_json::json; @@ -161,7 +161,8 @@ fn extract_f64(args: &serde_json::Value, key: &str, name: &str) -> Result().map_err(|_| format!("{name} is not a valid number: {s}")) + s.parse::() + .map_err(|_| format!("{name} is not a valid number: {s}")) } else { Err(format!("{name} must be a number")) } @@ -176,7 +177,8 @@ fn extract_i64(args: &serde_json::Value, key: &str, name: &str) -> Result().map_err(|_| format!("{name} is not a valid integer: {s}")) + s.parse::() + .map_err(|_| format!("{name} is not a valid integer: {s}")) } else { Err(format!("{name} must be an integer")) } @@ -755,7 +757,13 @@ mod tests { .await .unwrap(); assert!(!result.success); - assert!(result.error.as_ref().unwrap().contains("x is not a valid number")); + assert!( + result + .error + .as_ref() + .unwrap() + .contains("x is not a valid number") + ); } #[tokio::test] @@ -763,6 +771,12 @@ mod tests { let tool = CalculatorTool::new(); let result = tool.execute(serde_json::Value::Null).await.unwrap(); assert!(!result.success); - assert!(result.error.as_ref().unwrap().contains("Missing required parameters")); + assert!( + result + .error + .as_ref() + .unwrap() + .contains("Missing required parameters") + ); } } diff --git a/src/tools/file_edit.rs b/src/tools/file_edit.rs index c751235..75899ec 100644 --- a/src/tools/file_edit.rs +++ b/src/tools/file_edit.rs @@ -3,8 +3,8 @@ use std::path::Path; use async_trait::async_trait; use serde_json::json; -use crate::tools::traits::{Tool, ToolResult}; use crate::tools::extract_bool; +use crate::tools::traits::{Tool, ToolResult}; pub struct FileEditTool { allowed_dir: Option, @@ -43,21 +43,28 @@ impl FileEditTool { Ok(c) => c, Err(_) => { // File doesn't exist yet; canonicalize parent directory - let parent = resolved.parent().ok_or_else(|| { - format!("Path '{}' has no parent directory", path) - })?; + let parent = resolved + .parent() + .ok_or_else(|| format!("Path '{}' has no parent directory", path))?; let canonical_parent = std::fs::canonicalize(parent).map_err(|e| { - format!("Failed to canonicalize parent directory of '{}': {}", path, e) + format!( + "Failed to canonicalize parent directory of '{}': {}", + path, e + ) })?; - canonical_parent.join(resolved.file_name().ok_or_else(|| { - format!("Path '{}' has no file name component", path) - })?) + canonical_parent.join( + resolved + .file_name() + .ok_or_else(|| format!("Path '{}' has no file name component", path))?, + ) } }; if !canonical_resolved.starts_with(&canonical_allowed) { return Err(format!( "Path '{}' (resolves to '{}') is outside allowed directory '{}'", - path, canonical_resolved.display(), canonical_allowed.display() + path, + canonical_resolved.display(), + canonical_allowed.display() )); } } diff --git a/src/tools/file_read.rs b/src/tools/file_read.rs index 5ba4074..fc57887 100644 --- a/src/tools/file_read.rs +++ b/src/tools/file_read.rs @@ -4,8 +4,8 @@ use async_trait::async_trait; use serde_json::json; use crate::text::take_prefix_chars; -use crate::tools::traits::{Tool, ToolResult}; use crate::tools::extract_u64; +use crate::tools::traits::{Tool, ToolResult}; const MAX_CHARS: usize = 100_000; const DEFAULT_LIMIT: usize = 2000; @@ -48,7 +48,9 @@ impl FileReadTool { if !canonical_resolved.starts_with(&canonical_allowed) { return Err(format!( "Path '{}' (resolves to '{}') is outside allowed directory '{}'", - path, canonical_resolved.display(), canonical_allowed.display() + path, + canonical_resolved.display(), + canonical_allowed.display() )); } } diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs index ef07ea5..df8cf09 100644 --- a/src/tools/file_write.rs +++ b/src/tools/file_write.rs @@ -42,21 +42,28 @@ impl FileWriteTool { Ok(c) => c, Err(_) => { // File doesn't exist yet; canonicalize parent directory - let parent = resolved.parent().ok_or_else(|| { - format!("Path '{}' has no parent directory", path) - })?; + let parent = resolved + .parent() + .ok_or_else(|| format!("Path '{}' has no parent directory", path))?; let canonical_parent = std::fs::canonicalize(parent).map_err(|e| { - format!("Failed to canonicalize parent directory of '{}': {}", path, e) + format!( + "Failed to canonicalize parent directory of '{}': {}", + path, e + ) })?; - canonical_parent.join(resolved.file_name().ok_or_else(|| { - format!("Path '{}' has no file name component", path) - })?) + canonical_parent.join( + resolved + .file_name() + .ok_or_else(|| format!("Path '{}' has no file name component", path))?, + ) } }; if !canonical_resolved.starts_with(&canonical_allowed) { return Err(format!( "Path '{}' (resolves to '{}') is outside allowed directory '{}'", - path, canonical_resolved.display(), canonical_allowed.display() + path, + canonical_resolved.display(), + canonical_allowed.display() )); } } diff --git a/src/tools/memory_manage.rs b/src/tools/memory_manage.rs index a66e818..99e2713 100644 --- a/src/tools/memory_manage.rs +++ b/src/tools/memory_manage.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use serde_json::json; -use crate::storage::{is_valid_namespace, MemoryRecord, MemoryRepository, MemoryUpsert}; +use crate::storage::{MemoryRecord, MemoryRepository, MemoryUpsert, is_valid_namespace}; use crate::tools::traits::{Tool, ToolContext, ToolResult}; pub struct MemoryManageTool { diff --git a/src/tools/memory_search.rs b/src/tools/memory_search.rs index ff166c0..8e1e257 100644 --- a/src/tools/memory_search.rs +++ b/src/tools/memory_search.rs @@ -4,8 +4,8 @@ use async_trait::async_trait; use serde_json::json; use crate::storage::{MemoryRecord, MemoryRepository}; -use crate::tools::traits::{Tool, ToolContext, ToolResult}; use crate::tools::extract_u64; +use crate::tools::traits::{Tool, ToolContext, ToolResult}; pub struct MemorySearchTool { memories: Arc, @@ -103,8 +103,7 @@ impl Tool for MemorySearchTool { Some(value) => { // 支持两种格式:实际数组 或 字符串化的数组 if let Some(arr) = value.as_array() { - arr - .iter() + arr.iter() .filter_map(|v| v.as_str()) .map(str::trim) .filter(|v| !v.is_empty()) @@ -133,7 +132,7 @@ impl Tool for MemorySearchTool { vec![] } } - None => vec![] + None => vec![], }; if queries.is_empty() { return Ok(error_result("Missing required parameter: queries")); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c3d960a..6f97921 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -8,8 +8,8 @@ pub mod memory_manage; pub mod memory_search; pub mod registry; pub mod scheduler_manage; -pub mod session_send; pub mod schema; +pub mod session_send; pub mod shell_session; pub mod skill_activate; pub mod skill_manage; @@ -30,11 +30,11 @@ pub use memory_manage::MemoryManageTool; pub use memory_search::MemorySearchTool; pub use registry::ToolRegistry; pub use scheduler_manage::SchedulerManageTool; -pub use session_send::{ - NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest, - SessionSendTool, -}; pub use schema::{CleaningStrategy, SchemaCleanr}; +pub use session_send::{ + NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest, + SessionSendTool, +}; pub use shell_session::ShellSessionManager; pub use skill_activate::SkillActivateTool; pub use skill_manage::SkillManageTool; @@ -127,26 +127,22 @@ pub fn require_string(args: &serde_json::Value, key: &str) -> Result Result { - extract_f64(args, key) - .ok_or_else(|| format!("Missing required parameter: {}", key)) + extract_f64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key)) } /// Extract a required i64 parameter, returning an error message if missing. pub fn require_i64(args: &serde_json::Value, key: &str) -> Result { - extract_i64(args, key) - .ok_or_else(|| format!("Missing required parameter: {}", key)) + extract_i64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key)) } /// Extract a required u64 parameter, returning an error message if missing. pub fn require_u64(args: &serde_json::Value, key: &str) -> Result { - extract_u64(args, key) - .ok_or_else(|| format!("Missing required parameter: {}", key)) + extract_u64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key)) } /// Extract a required bool parameter, returning an error message if missing. pub fn require_bool(args: &serde_json::Value, key: &str) -> Result { - extract_bool(args, key) - .ok_or_else(|| format!("Missing required parameter: {}", key)) + extract_bool(args, key).ok_or_else(|| format!("Missing required parameter: {}", key)) } /// Extract a string array parameter, handling both actual arrays and stringified JSON arrays. @@ -207,7 +203,13 @@ pub fn check_null_args(args: &serde_json::Value, tool_name: &str) -> Option bool { - !self.tools + !self + .tools .read() .expect("ToolRegistry lock poisoned") .is_empty() @@ -84,7 +85,10 @@ impl ToolRegistry { .map(|(k, v)| (k.clone(), v.clone())) .collect(); let new_registry = ToolRegistry::new(); - *new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered; + *new_registry + .tools + .write() + .expect("ToolRegistry lock poisoned") = filtered; new_registry } @@ -99,7 +103,10 @@ impl ToolRegistry { .map(|(k, v)| (k.clone(), v.clone())) .collect(); let new_registry = ToolRegistry::new(); - *new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered; + *new_registry + .tools + .write() + .expect("ToolRegistry lock poisoned") = filtered; new_registry } } diff --git a/src/tools/session_send.rs b/src/tools/session_send.rs index f175ff3..b14b3d3 100644 --- a/src/tools/session_send.rs +++ b/src/tools/session_send.rs @@ -132,13 +132,7 @@ impl Tool for SessionSendTool { let outcome = match self .sender - .send_to_current_session( - context, - SessionSendRequest { - text, - attachments, - }, - ) + .send_to_current_session(context, SessionSendRequest { text, attachments }) .await { Ok(outcome) => outcome, @@ -154,7 +148,12 @@ impl Tool for SessionSendTool { } fn validate_context(context: &ToolContext) -> anyhow::Result<()> { - if context.channel_name.as_deref().unwrap_or_default().is_empty() { + if context + .channel_name + .as_deref() + .unwrap_or_default() + .is_empty() + { return Err(anyhow!( "send_session_message requires channel_name in tool context" )); @@ -413,8 +412,7 @@ fn filename_matches_target(on_disk_name: &std::ffi::OsStr, target: &str) -> bool fn parse_attachments(value: &serde_json::Value) -> anyhow::Result> { // 支持两种格式:实际数组 或 字符串化的 JSON 数组 let paths = if let Some(arr) = value.as_array() { - arr - .iter() + arr.iter() .filter_map(|v| v.as_str()) .map(str::trim) .filter(|v| !v.is_empty()) @@ -565,7 +563,10 @@ mod tests { .unwrap(); assert!(result.success); - assert_eq!(result.output, "Sent 1 text message to the current conversation."); + assert_eq!( + result.output, + "Sent 1 text message to the current conversation." + ); } #[tokio::test] @@ -597,8 +598,8 @@ mod tests { let image_path = file.path().with_extension("png"); std::fs::rename(file.path(), &image_path).unwrap(); - let attachments = parse_attachments(&json!([image_path.to_string_lossy().to_string()])) - .unwrap(); + let attachments = + parse_attachments(&json!([image_path.to_string_lossy().to_string()])).unwrap(); assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].media_type, "image"); @@ -651,4 +652,4 @@ mod tests { // 验证文件名能正确提取(用 lossy 方式,因为是 GBK 编码) assert!(attachments[0].file_name.is_some()); } -} \ No newline at end of file +} diff --git a/src/tools/skill_activate.rs b/src/tools/skill_activate.rs index 52d3455..c0bd47c 100644 --- a/src/tools/skill_activate.rs +++ b/src/tools/skill_activate.rs @@ -5,8 +5,8 @@ use serde_json::json; use crate::skills::SkillRuntime; use crate::storage::SkillEventRepository; -use crate::tools::traits::{Tool, ToolContext, ToolResult}; use crate::tools::check_null_args; +use crate::tools::traits::{Tool, ToolContext, ToolResult}; pub struct SkillActivateTool { skills: Arc, @@ -135,7 +135,9 @@ mod tests { async fn test_skill_activate_records_failed_activation_event() { let skills = Arc::new(SkillRuntime::default()); let store = Arc::new(SessionStore::in_memory().unwrap()); - store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); + store + .ensure_channel_session(TEST_CHANNEL, "chat-1") + .unwrap(); let tool = SkillActivateTool::new(skills, store.clone()); let context = ToolContext { session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), @@ -162,7 +164,9 @@ mod tests { async fn test_skill_activate_handles_null_args() { let skills = Arc::new(SkillRuntime::default()); let store = Arc::new(SessionStore::in_memory().unwrap()); - store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); + store + .ensure_channel_session(TEST_CHANNEL, "chat-1") + .unwrap(); let tool = SkillActivateTool::new(skills, store.clone()); let context = ToolContext { session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), @@ -175,6 +179,11 @@ mod tests { .unwrap(); assert!(!result.success); - assert!(result.error.unwrap().contains("Missing required parameters")); + assert!( + result + .error + .unwrap() + .contains("Missing required parameters") + ); } } diff --git a/src/tools/task/error.rs b/src/tools/task/error.rs index fc5bcf9..07d4ac6 100644 --- a/src/tools/task/error.rs +++ b/src/tools/task/error.rs @@ -45,4 +45,4 @@ impl TaskError { Self::InvalidArguments(_) => "failed", } } -} \ No newline at end of file +} diff --git a/src/tools/task/mod.rs b/src/tools/task/mod.rs index 1f1e6c2..bdd2be2 100644 --- a/src/tools/task/mod.rs +++ b/src/tools/task/mod.rs @@ -8,6 +8,12 @@ pub mod types; pub use error::TaskError; pub use prompt::SubagentPromptBuilder; pub use repository::{InMemoryTaskRepository, TaskRepository}; -pub use runtime::{DefaultSubAgentRuntime, SubAgentRuntime, SubAgentRuntimeConfig, SubagentCatalog, StaticSystemPromptProvider}; +pub use runtime::{ + DefaultSubAgentRuntime, StaticSystemPromptProvider, SubAgentRuntime, SubAgentRuntimeConfig, + SubagentCatalog, +}; pub use tool::TaskTool; -pub use types::{SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult}; \ No newline at end of file +pub use types::{ + SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession, + TaskSessionState, TaskToolArgs, TaskToolResult, +}; diff --git a/src/tools/task/repository.rs b/src/tools/task/repository.rs index de2e8cc..440fbdc 100644 --- a/src/tools/task/repository.rs +++ b/src/tools/task/repository.rs @@ -135,4 +135,4 @@ fn current_timestamp() -> i64 { .duration_since(std::time::UNIX_EPOCH) .expect("system clock before unix epoch") .as_millis() as i64 -} \ No newline at end of file +} diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 4519f25..d64a2de 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -7,20 +7,23 @@ use std::time::Duration; use async_trait::async_trait; use serde::Deserialize; -use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider}; +use crate::agent::{ + AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, + SystemPrompt, SystemPromptContext, SystemPromptProvider, +}; use crate::bus::ChatMessage; -use crate::bus::message::{OutboundMessage, OutboundEventKind}; use crate::bus::MessageBus; -use crate::domain::CapabilityPolicy; -use crate::providers::StreamDelta; +use crate::bus::message::{OutboundEventKind, OutboundMessage}; use crate::config::{LLMProviderConfig, SubagentsConfig}; +use crate::domain::CapabilityPolicy; use crate::experts::ExpertRuntime; +use crate::providers::StreamDelta; use crate::skills::SkillRuntime; use crate::storage::{ConversationRepository, SessionStore}; use crate::tools::{ToolContext, ToolRegistry}; use super::error::TaskError; -use super::prompt::{extract_summary, SubagentPromptBuilder}; +use super::prompt::{SubagentPromptBuilder, extract_summary}; use super::repository::TaskRepository; use super::tool::TaskTool; use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult}; @@ -53,7 +56,7 @@ impl Default for SubAgentRuntimeConfig { "calculator".to_string(), "skill_activate".to_string(), "skill_list".to_string(), - "send_session_message".to_string(), // 用于进度通知 + "send_session_message".to_string(), // 用于进度通知 ]), default_max_execution_secs: 3600, // 60分钟 ttl_hours: 24, @@ -167,7 +170,9 @@ impl EmittedMessageHandler for SubAgentEmitter { async fn handle_stream_delta(&self, delta: &StreamDelta) { let message_id = { let mut guard = self.stream_message_id.lock().unwrap(); - guard.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone() + guard + .get_or_insert_with(|| uuid::Uuid::new_v4().to_string()) + .clone() }; let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() { @@ -289,10 +294,7 @@ fn build_subagent_event_metadata(session: &TaskSession) -> HashMap>, - session: &TaskSession, -) { +async fn publish_subagent_completion(bus: &Option>, session: &TaskSession) { if let Some(bus) = bus { let metadata = build_subagent_event_metadata(session); if let Err(e) = bus @@ -475,17 +477,20 @@ impl DefaultSubAgentRuntime { // 按 def 中的 provider/model 字段解析覆盖基础 provider_config。 // 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。 let effective_provider_config = match def { - Some(d) if d.provider.is_some() || d.model.is_some() => { - self.model_resolver - .resolve(d.provider.as_deref(), d.model.as_deref(), &self.provider_config) - .map_err(|e| { - TaskError::AgentCreationFailed(format!( - "subagent '{}' model resolution failed: {}", - def.map(|d| d.name.as_str()).unwrap_or("?"), - e - )) - })? - } + Some(d) if d.provider.is_some() || d.model.is_some() => self + .model_resolver + .resolve( + d.provider.as_deref(), + d.model.as_deref(), + &self.provider_config, + ) + .map_err(|e| { + TaskError::AgentCreationFailed(format!( + "subagent '{}' model resolution failed: {}", + def.map(|d| d.name.as_str()).unwrap_or("?"), + e + )) + })?, _ => self.provider_config.clone(), }; @@ -519,7 +524,10 @@ impl DefaultSubAgentRuntime { let mut metadata = HashMap::new(); metadata.insert("subagent_task_id".to_string(), session.id.clone()); metadata.insert("is_subagent_event".to_string(), "true".to_string()); - metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default()); + metadata.insert( + "topic_id".to_string(), + session.parent_topic_id.clone().unwrap_or_default(), + ); let emitter = Arc::new(PersistingEmittedMessageHandler::new( SubAgentEmitter { @@ -703,8 +711,14 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { let mut metadata = HashMap::new(); metadata.insert("task_id".to_string(), session.id.clone()); metadata.insert("task_description".to_string(), session.description.clone()); - metadata.insert("task_subagent_type".to_string(), session.subagent_type.clone()); - metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default()); + metadata.insert( + "task_subagent_type".to_string(), + session.subagent_type.clone(), + ); + metadata.insert( + "topic_id".to_string(), + session.parent_topic_id.clone().unwrap_or_default(), + ); // 如果是子智能体创建的孙智能体,传递父 task_id if let Some(ref ptid) = parent_context.task_id { @@ -752,11 +766,17 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) { (true, _) | (_, true) => self .model_resolver - .resolve(def.provider.as_deref(), def.model.as_deref(), &self.provider_config) - .map_err(|e| TaskError::AgentCreationFailed(format!( - "subagent '{}' model resolution failed: {}", - def.name, e - )))?, + .resolve( + def.provider.as_deref(), + def.model.as_deref(), + &self.provider_config, + ) + .map_err(|e| { + TaskError::AgentCreationFailed(format!( + "subagent '{}' model resolution failed: {}", + def.name, e + )) + })?, _ => self.provider_config.clone(), }; let system_prompt = SubagentPromptBuilder::build( @@ -768,7 +788,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { ); // 7. 创建子代理 - let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?; + let agent = self.create_subagent( + &session, + system_prompt, + Some(&def), + parent_context.nesting_depth, + parent_context.task_id.clone(), + )?; // 8. 执行任务 let result = self @@ -836,7 +862,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { } // 3. 确保 sessions 表中存在子智能体会话记录 - let session_title = format!("Subagent [{}]: {}", session.subagent_type, session.description); + let session_title = format!( + "Subagent [{}]: {}", + session.subagent_type, session.description + ); if let Err(e) = self.conversation_repository.ensure_session( &session.session_id, &session.parent_channel_name, @@ -847,10 +876,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { } // 4. 构建恢复提示词 - let system_prompt = SubagentPromptBuilder::build_resume_prompt( - &session.description, - &additional_prompt, - ); + let system_prompt = + SubagentPromptBuilder::build_resume_prompt(&session.description, &additional_prompt); // 4.1 校验父智能体的子代理策略(白/黑名单)。 // 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许 @@ -870,7 +897,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { .map_err(TaskError::InvalidArguments)?; // 5. 创建子代理 - let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?; + let agent = self.create_subagent( + &session, + system_prompt, + Some(&def), + parent_context.nesting_depth, + parent_context.task_id.clone(), + )?; // 6. 使用历史继续执行 let result = self @@ -901,7 +934,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> { // TODO: 实现双向通信 // 需要在 TaskSession 中添加 pending_messages 队列 - Err(TaskError::InvalidArguments("send_message not implemented yet".to_string())) + Err(TaskError::InvalidArguments( + "send_message not implemented yet".to_string(), + )) } async fn cleanup_expired(&self) -> Result { @@ -945,7 +980,8 @@ impl SubagentCatalog { fn discover_with_cwd(config: &SubagentsConfig, cwd: &Path) -> Self { // 先内置作为基础 - let mut merged: std::collections::HashMap = std::collections::HashMap::new(); + let mut merged: std::collections::HashMap = + std::collections::HashMap::new(); merged.insert("general".to_string(), SubagentDef::builtin_general()); tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd"); @@ -1023,7 +1059,7 @@ impl SubagentCatalog { "# 子代理系统\n\n\ 子代理是专用的执行单元,用于处理特定类型的任务。\n\ 创建子代理任务时,可以选择以下类型之一:\n\n\ - \n" + \n", ); for def in defs { @@ -1223,15 +1259,24 @@ impl SubagentRuntime { /// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。 pub fn reload(&self) -> Result<(), String> { let new_catalog = SubagentCatalog::discover(&self.config); - let mut guard = self.catalog.write().expect("subagent catalog rwlock poisoned"); + let mut guard = self + .catalog + .write() + .expect("subagent catalog rwlock poisoned"); *guard = new_catalog; Ok(()) } /// 列出所有子代理(含禁用项),带 disabled_in_scopes pub fn list_with_status(&self) -> Vec { - let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let state = self + .disable_state + .read() + .expect("subagent state rwlock poisoned"); + let catalog = self + .catalog + .read() + .expect("subagent catalog rwlock poisoned"); let mut items: Vec = catalog .all() .iter() @@ -1254,8 +1299,14 @@ impl SubagentRuntime { /// 可用子代理名称(过滤禁用项) pub fn available_names(&self) -> Vec { - let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let state = self + .disable_state + .read() + .expect("subagent state rwlock poisoned"); + let catalog = self + .catalog + .read() + .expect("subagent catalog rwlock poisoned"); catalog .names() .into_iter() @@ -1265,17 +1316,30 @@ impl SubagentRuntime { /// 查找可用子代理(过滤禁用项) pub fn find_available(&self, name: &str) -> Option { - let state = self.disable_state.read().expect("subagent state rwlock poisoned"); + let state = self + .disable_state + .read() + .expect("subagent state rwlock poisoned"); if state.is_disabled(name) { return None; } - self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).cloned() + self.catalog + .read() + .expect("subagent catalog rwlock poisoned") + .find(name) + .cloned() } /// 生成过滤后的系统索引提示词 pub fn system_index_prompt_filtered(&self) -> Option { - let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let state = self + .disable_state + .read() + .expect("subagent state rwlock poisoned"); + let catalog = self + .catalog + .read() + .expect("subagent catalog rwlock poisoned"); let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() @@ -1313,8 +1377,14 @@ impl SubagentRuntime { allowed: Option<&[String]>, denied: &[String], ) -> Option { - let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let state = self + .disable_state + .read() + .expect("subagent state rwlock poisoned"); + let catalog = self + .catalog + .read() + .expect("subagent catalog rwlock poisoned"); let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() @@ -1377,7 +1447,13 @@ impl SubagentRuntime { enabled: bool, ) -> Result { // 校验子代理存在 - if self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).is_none() { + if self + .catalog + .read() + .expect("subagent catalog rwlock poisoned") + .find(name) + .is_none() + { return Err(format!("subagent '{}' not found", name)); } @@ -1452,7 +1528,10 @@ impl SubagentRuntime { reload: bool, ) -> Result { let def = { - let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let catalog = self + .catalog + .read() + .expect("subagent catalog rwlock poisoned"); catalog .find(name) .ok_or_else(|| format!("subagent '{}' not found", name))? @@ -1471,7 +1550,9 @@ impl SubagentRuntime { let next_description = description.unwrap_or(&def.description); let next_body = body.unwrap_or(def.body.as_deref().unwrap_or("")); - let next_capability = capability.cloned().unwrap_or_else(|| def.capability.clone()); + let next_capability = capability + .cloned() + .unwrap_or_else(|| def.capability.clone()); let next_provider = provider.cloned().unwrap_or(def.provider); let next_model = model.cloned().unwrap_or(def.model); @@ -1516,15 +1597,14 @@ impl SystemPromptProvider for SubagentPromptProvider { // 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认) let content = match context.session_id.as_deref() { Some(sid) => { - let policy = self - .experts - .selected_expert_for(sid) - .map(|e| e.capability); + let policy = self.experts.selected_expert_for(sid).map(|e| e.capability); match policy { - Some(p) if p.has_subagent_policy() => self.runtime.system_index_prompt_filtered_with_policy( - p.allowed_subagents.as_deref(), - &p.denied_subagents, - ), + Some(p) if p.has_subagent_policy() => { + self.runtime.system_index_prompt_filtered_with_policy( + p.allowed_subagents.as_deref(), + &p.denied_subagents, + ) + } _ => self.runtime.system_index_prompt_filtered(), } } @@ -1671,8 +1751,7 @@ fn load_subagents_from_root(root: &Path, source: SubagentSource) -> Vec Result { - let content = fs::read_to_string(path) - .map_err(|e| format!("failed to read file: {}", e))?; + let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?; let (frontmatter, body) = match crate::frontmatter::parse::(&content) { Ok(v) => v, @@ -1695,7 +1774,11 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result Result { return Ok(error_result( "todo_read requires session_id or topic_id in tool context", - )) + )); } }; @@ -140,11 +140,7 @@ impl Tool for TodoReadTool { // ── 辅助函数 ────────────────────────────────────────────── -fn success_result( - items: &[TodoItem], - scope_key: &str, - source: &'static str, -) -> ToolResult { +fn success_result(items: &[TodoItem], scope_key: &str, source: &'static str) -> ToolResult { let output = TodoReadOutput { todos: items.to_vec(), count: items.len(), @@ -212,11 +208,21 @@ mod tests { &self, scope_key: &str, ) -> Result, crate::storage::StorageError> { - Ok(self.records.iter().filter(|r| r.scope_key == scope_key).cloned().collect()) + Ok(self + .records + .iter() + .filter(|r| r.scope_key == scope_key) + .cloned() + .collect()) } } - fn mock_record(scope_key: &str, id: &str, content: &str, status: &str) -> crate::storage::TodoRecord { + fn mock_record( + scope_key: &str, + id: &str, + content: &str, + status: &str, + ) -> crate::storage::TodoRecord { crate::storage::TodoRecord { id: id.to_string(), scope_key: scope_key.to_string(), @@ -250,7 +256,10 @@ mod tests { let repo = Arc::new(MockTodoRepository { records: vec![] }); let tool = TodoReadTool::new(state, repo); - let result = tool.execute_with_context(&test_context(), json!({})).await.unwrap(); + let result = tool + .execute_with_context(&test_context(), json!({})) + .await + .unwrap(); assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); @@ -270,7 +279,10 @@ mod tests { }); let tool = TodoReadTool::new(state.clone(), repo); - let result = tool.execute_with_context(&test_context(), json!({})).await.unwrap(); + let result = tool + .execute_with_context(&test_context(), json!({})) + .await + .unwrap(); assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); @@ -289,7 +301,10 @@ mod tests { let repo = Arc::new(MockTodoRepository { records: vec![] }); let tool = TodoReadTool::new(state, repo); - let result = tool.execute_with_context(&test_context(), json!({})).await.unwrap(); + let result = tool + .execute_with_context(&test_context(), json!({})) + .await + .unwrap(); assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); @@ -324,9 +339,7 @@ mod tests { } let repo = Arc::new(MockTodoRepository { - records: vec![ - mock_record("topic-xyz", "t1", "话题任务", "completed"), - ], + records: vec![mock_record("topic-xyz", "t1", "话题任务", "completed")], }); let tool = TodoReadTool::new(state, repo); @@ -336,7 +349,10 @@ mod tests { ..ToolContext::default() }; - let result = tool.execute_with_context(&topic_ctx, json!({})).await.unwrap(); + let result = tool + .execute_with_context(&topic_ctx, json!({})) + .await + .unwrap(); assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); diff --git a/src/tools/todo_write.rs b/src/tools/todo_write.rs index 58c605a..9c17c47 100644 --- a/src/tools/todo_write.rs +++ b/src/tools/todo_write.rs @@ -136,7 +136,9 @@ impl Tool for TodoWriteTool { } async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(error_result("todo_write requires tool context (session_id)")) + Ok(error_result( + "todo_write requires tool context (session_id)", + )) } async fn execute_with_context( @@ -147,7 +149,11 @@ impl Tool for TodoWriteTool { // 1. 计算 scope_key let scope_key = match scope_key_from_context(context) { Some(key) => key, - None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")), + None => { + return Ok(error_result( + "todo_write requires session_id or topic_id in tool context", + )); + } }; // 2. 提取当前 tool call ID(用于定位修改该项的 tool 消息,前端用 tool_call_id 作为 data-message-id) @@ -156,13 +162,14 @@ impl Tool for TodoWriteTool { // 3. 解析入参 let todos_array = match args.get("todos").and_then(|v| v.as_array()) { Some(arr) => arr, - None => return Ok(error_result("Missing required parameter: todos (must be an array)")), + None => { + return Ok(error_result( + "Missing required parameter: todos (must be an array)", + )); + } }; - let merge_mode = args - .get("merge") - .and_then(|v| v.as_bool()) - .unwrap_or(true); + let merge_mode = args.get("merge").and_then(|v| v.as_bool()).unwrap_or(true); // 3. 读锁获取旧状态;内存为空时从 DB 回填(与 TodoReadTool 一致,保证 merge 模式不丢失旧项) let old_items = { @@ -172,17 +179,15 @@ impl Tool for TodoWriteTool { _ => { drop(guard); let db_items = match self.repository.list_todos(&scope_key) { - Ok(records) if !records.is_empty() => { - records - .into_iter() - .map(|r| TodoItem { - id: r.id, - content: r.content, - status: r.status, - created_by_message_id: r.created_by_message_id, - }) - .collect::>() - } + Ok(records) if !records.is_empty() => records + .into_iter() + .map(|r| TodoItem { + id: r.id, + content: r.content, + status: r.status, + created_by_message_id: r.created_by_message_id, + }) + .collect::>(), _ => Vec::new(), }; if !db_items.is_empty() { @@ -200,7 +205,10 @@ impl Tool for TodoWriteTool { }; // 构建 id → TodoItem 的旧状态映射 - let old_map: HashMap<&str, &TodoItem> = old_items.iter().map(|item| (item.id.as_str(), item)).collect(); + let old_map: HashMap<&str, &TodoItem> = old_items + .iter() + .map(|item| (item.id.as_str(), item)) + .collect(); // 4. 解析并校验每个输入项 let mut processed_items: Vec = Vec::new(); @@ -255,8 +263,8 @@ impl Tool for TodoWriteTool { } // 仅在 content 或 status 实际变化时更新 created_by_message_id - let changed = old_item.content != content - || old_item.status.as_str() != new_status.as_str(); + let changed = + old_item.content != content || old_item.status.as_str() != new_status.as_str(); processed_items.push(TodoItem { id, content, @@ -273,7 +281,8 @@ impl Tool for TodoWriteTool { let old_status = match TodoStatus::from_str(&old_item.status) { Some(s) => s, None => { - validation_errors.push(format!("Item '{}': corrupted old status", content)); + validation_errors + .push(format!("Item '{}': corrupted old status", content)); continue; } }; @@ -322,8 +331,10 @@ impl Tool for TodoWriteTool { } // 5. 合并模式:将旧列表中未被引用的项保留 - let processed_ids: std::collections::HashSet<&str> = - processed_items.iter().map(|item| item.id.as_str()).collect(); + let processed_ids: std::collections::HashSet<&str> = processed_items + .iter() + .map(|item| item.id.as_str()) + .collect(); let final_items: Vec = if merge_mode { let mut merged = processed_items.clone(); @@ -350,7 +361,8 @@ impl Tool for TodoWriteTool { } // 7. 计算 removed 数量(仅全量替换模式) - let final_ids: std::collections::HashSet<&str> = final_items.iter().map(|item| item.id.as_str()).collect(); + let final_ids: std::collections::HashSet<&str> = + final_items.iter().map(|item| item.id.as_str()).collect(); let removed_count = if merge_mode { 0 } else { @@ -412,24 +424,25 @@ fn validate_transition(old: &TodoStatus, new: &TodoStatus) -> Result<(), String> (TodoStatus::InProgress, TodoStatus::Cancelled) => Ok(()), (TodoStatus::InProgress, TodoStatus::InProgress) => Ok(()), (TodoStatus::InProgress, TodoStatus::Pending) => Err( - "Cannot move an in_progress task back to pending. Use completed or cancelled.".to_string(), + "Cannot move an in_progress task back to pending. Use completed or cancelled." + .to_string(), ), // completed → can reactivate to in_progress or pending (TodoStatus::Completed, TodoStatus::InProgress) => Ok(()), (TodoStatus::Completed, TodoStatus::Pending) => Ok(()), (TodoStatus::Completed, TodoStatus::Completed) => Ok(()), - (TodoStatus::Completed, TodoStatus::Cancelled) => Err( - "Cannot cancel a completed task. Move it to pending first if needed.".to_string(), - ), + (TodoStatus::Completed, TodoStatus::Cancelled) => { + Err("Cannot cancel a completed task. Move it to pending first if needed.".to_string()) + } // cancelled → can reactivate to pending or in_progress (TodoStatus::Cancelled, TodoStatus::Pending) => Ok(()), (TodoStatus::Cancelled, TodoStatus::InProgress) => Ok(()), (TodoStatus::Cancelled, TodoStatus::Cancelled) => Ok(()), - (TodoStatus::Cancelled, TodoStatus::Completed) => Err( - "Cannot complete a cancelled task. Move it to pending first if needed.".to_string(), - ), + (TodoStatus::Cancelled, TodoStatus::Completed) => { + Err("Cannot complete a cancelled task. Move it to pending first if needed.".to_string()) + } } } @@ -590,7 +603,12 @@ mod tests { .unwrap(); assert!(!result.success); - assert!(result.error.unwrap().contains("Only one task can be 'in_progress'")); + assert!( + result + .error + .unwrap() + .contains("Only one task can be 'in_progress'") + ); } #[tokio::test] @@ -784,7 +802,12 @@ mod tests { .unwrap(); assert!(!result.success); - assert!(result.error.unwrap().contains("Cannot move an in_progress task back to pending")); + assert!( + result + .error + .unwrap() + .contains("Cannot move an in_progress task back to pending") + ); } #[tokio::test] @@ -931,10 +954,7 @@ mod tests { let context = test_context(); let result = tool - .execute_with_context( - &context, - json!({"todos": []}), - ) + .execute_with_context(&context, json!({"todos": []})) .await .unwrap(); @@ -1215,10 +1235,22 @@ mod tests { let todos = output["current_todos"].as_array().unwrap(); assert_eq!(todos.len(), 3); // content fallback 匹配后应使用旧 id - assert_eq!(todos.iter().find(|t| t["content"] == "任务1").unwrap()["id"], "q1"); - assert_eq!(todos.iter().find(|t| t["content"] == "任务1").unwrap()["status"], "completed"); - assert_eq!(todos.iter().find(|t| t["content"] == "任务2").unwrap()["status"], "completed"); - assert_eq!(todos.iter().find(|t| t["content"] == "任务3").unwrap()["status"], "cancelled"); + assert_eq!( + todos.iter().find(|t| t["content"] == "任务1").unwrap()["id"], + "q1" + ); + assert_eq!( + todos.iter().find(|t| t["content"] == "任务1").unwrap()["status"], + "completed" + ); + assert_eq!( + todos.iter().find(|t| t["content"] == "任务2").unwrap()["status"], + "completed" + ); + assert_eq!( + todos.iter().find(|t| t["content"] == "任务3").unwrap()["status"], + "cancelled" + ); } #[tokio::test] @@ -1327,10 +1359,7 @@ mod tests { let ids: Vec<&str> = todos.iter().map(|t| t["id"].as_str().unwrap()).collect(); assert!(ids.contains(&"a")); assert!(ids.contains(&"b"), "pending item b must be preserved"); - assert!( - ids.contains(&"c"), - "in_progress item c must be preserved" - ); + assert!(ids.contains(&"c"), "in_progress item c must be preserved"); // 验证内存已被回填 let guard = state.read().await; diff --git a/src/topic_description.rs b/src/topic_description.rs index ea04462..8cb1eee 100644 --- a/src/topic_description.rs +++ b/src/topic_description.rs @@ -9,10 +9,7 @@ pub async fn generate_topic_description( let user_prompt = format!("用户消息:{}", first_user_message); let request = ChatCompletionRequest { - messages: vec![ - Message::system(system_prompt), - Message::user(user_prompt), - ], + messages: vec![Message::system(system_prompt), Message::user(user_prompt)], temperature: Some(0.0), max_tokens: Some(1024), // 给 reasoning 模型留足思考空间 tools: None, @@ -48,4 +45,4 @@ pub async fn generate_topic_description( } else { Ok(description) } -} \ No newline at end of file +} diff --git a/tests/test_request_format.rs b/tests/test_request_format.rs index 21b679b..3045f8d 100644 --- a/tests/test_request_format.rs +++ b/tests/test_request_format.rs @@ -83,7 +83,9 @@ fn test_message_inbound_serialization() { let decoded: WsInbound = serde_json::from_str(&json).unwrap(); match decoded { - WsInbound::Message { content, chat_id, .. } => { + WsInbound::Message { + content, chat_id, .. + } => { assert_eq!(content, "Hello world"); assert_eq!(chat_id.as_deref(), Some("session-1")); } diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..d46a268 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +coverage +*.local diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..6789c6c --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..ac16058 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,56 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; + +export default tseslint.config( + // 全局忽略 + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + // 基础推荐规则 + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + // React Hooks 规则升级为 error(这是真正的 bug 来源) + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + // 渐进式策略:TypeScript 推荐规则默认 warn,不阻断 + // 仅把最关键的几个升级为 error + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + // catch (_) 是"故意忽略错误"的惯用法,单独配置项控制 + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + // no-console 在测试环境外保留 console + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + }, + // 测试文件放宽规则 + { + files: ['src/**/*.test.{ts,tsx}', 'src/test/**'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + }, + }, +); diff --git a/web/package-lock.json b/web/package-lock.json index fd16971..adf73bd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,15 +18,22 @@ "typescript": "^6.0.3" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", "jsdom": "^29.1.1", "postcss": "^8.5.15", + "prettier": "^3.4.2", "tailwindcss": "^4.3.0", + "typescript-eslint": "^8.20.0", "vite": "^8.0.14", "vitest": "^4.1.10" } @@ -326,6 +333,163 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -344,6 +508,72 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1125,6 +1355,13 @@ "@types/unist": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1164,6 +1401,288 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -1309,6 +1828,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1334,6 +1893,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1401,6 +1967,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -1424,6 +1997,17 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1458,6 +2042,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -1499,6 +2093,39 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1539,6 +2166,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1549,6 +2196,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1556,6 +2210,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1634,6 +2303,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1737,6 +2413,186 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -1757,6 +2613,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1773,6 +2639,27 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1791,6 +2678,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1820,6 +2758,32 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1827,6 +2791,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -1890,6 +2864,43 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -1940,6 +2951,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -1969,6 +3003,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1987,6 +3028,29 @@ "license": "MIT", "peer": true }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -2028,6 +3092,51 @@ } } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2289,6 +3398,29 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -3199,6 +4331,19 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3224,6 +4369,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -3248,6 +4400,69 @@ "node": ">=12.20.0" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3286,6 +4501,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3349,6 +4584,32 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3531,6 +4792,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -3584,6 +4855,42 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3652,6 +4959,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3670,6 +4990,19 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3808,6 +5141,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3816,6 +5162,19 @@ "license": "0BSD", "optional": true }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3829,6 +5188,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -3957,6 +5340,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -4201,6 +5594,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4218,6 +5627,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4235,6 +5654,19 @@ "dev": true, "license": "MIT" }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web/package.json b/web/package.json index 2aa8811..093881b 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,11 @@ "build": "tsc && vite build", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"" }, "dependencies": { "@types/react": "^19.2.15", @@ -21,15 +25,22 @@ "typescript": "^6.0.3" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", "jsdom": "^29.1.1", "postcss": "^8.5.15", + "prettier": "^3.4.2", "tailwindcss": "^4.3.0", + "typescript-eslint": "^8.20.0", "vite": "^8.0.14", "vitest": "^4.1.10" }