diff --git a/AGENTS.md b/AGENTS.md index 0c1f384..3b557de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,10 @@ primary use Chinese!!! - 每个模块独立维护版本号,记录在各自 `Cargo.toml` 的 `package.version`。 - `ias-main` 的版本号代表整体项目版本,记录在根目录 `VERSION`。 -- 每次完成代码或行为变更,必须更新受影响模块的版本号;如果 `ias-main` 也受影响,同步更新 `VERSION`。 -- 同步更新 `CHANGELOG.md`,并通过 `service/migrations` 将升级记录写入 `ias_upgrade_logs`。 +- 版本号、`VERSION`、`CHANGELOG.md` 和 `ias_upgrade_logs` 记录在准备提交代码或用户明确要求收尾时统一处理;纯工作中间态不强制立即更新。 +- 只升级本次提交中实际发生代码、行为、配置或数据库结构变化的模块版本号;未改动模块保持原版本。 +- `ias-main` 作为整体项目版本,只要本次提交包含用户可感知的代码、行为、配置或数据库变化,就同步提升 `ias-main` 和根目录 `VERSION`。 +- 同步更新 `CHANGELOG.md`,并通过 `ias-db/migrations` 将升级记录写入 `ias_upgrade_logs`;仅新增升级日志迁移不视为 `ias-db` 模块功能变更,不单独触发 `ias-db` 版本升级。 - `ias_upgrade_logs` 的 `version` 对应 `ias-main` 整体版本,`modules` JSONB 字段记录各模块版本。 - `CHANGELOG.md` 与 `ias_upgrade_logs` 的版本、日期和变更要保持一致。 - 升级日志应使用中文撰写,记录用户可感知变化、数据库迁移、配置项变化和关键验证结果。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7d450..d83e2d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 更新日志 +## 0.2.27 - 2026-07-08 + +- 新增 `ias-db` crate,集中负责 PostgreSQL 连接池创建、`DATABASE_URL` 读取和 SQLx 迁移执行。 +- 将原 `ias-service/migrations` 整体迁移到 `ias-db/migrations`,迁移嵌入和变更监听由 `ias-db/build.rs` 负责。 +- `ias-main` 改为直接调用 `ias_db::connect_db()`,`ias-service` 只接收已建立的 `PgPool` 并运行服务,数据库生命周期边界更清晰。 +- 新增操作日志 HTTP 查询接口:`GET /v1/logs`(分页查询,支持按级别、模块、关键词筛选)和 `GET /v1/logs/{id}`(单条详情),API 文档新增 `logs` 功能分组。 +- 新增操作日志 Web 前端页面,支持按级别、模块和关键词筛选查看日志,便于排障和审计。 +- 消息队列关键节点增加操作日志写入:LLM 响应、渠道消息接收、上下文记录错误、消息发送失败等场景自动记录到 `ias_operation_logs`。 +- `ias-mail` 新增 `mark_remote_message_seen` 函数,通过 IMAP 设置邮件已读并验证服务器确认,抽取 `connect_imap_session` 复用连接逻辑。 +- 工作流配置加载改为文件级容错:`ias-ai` 新增 `load_configs_with_warnings`,返回 `LoadedFlowConfigs` 包含 warnings 信息,单个 YAML 配置损坏时不会导致所有 HTTP 能力为空。 +- `ias-context` 工具调用说明中的能力引用路径从已废弃的 `record_tool_call_instruction` 更新为 `query_api_docs` + `call_http_api`,并新增单元测试验证。 +- 新增高德地图地理编码/逆地理编码 HTTP 能力配置文件 `tools/amap_geocode.yaml`。 +- 数据库无结构变化;新增 `0.2.27` 升级日志记录,新增 `ias-db` `0.1.0`,`ias-ai` 升级至 `0.1.14`,`ias-context` 升级至 `0.1.6`,`ias-http` 升级至 `0.1.15`,`ias-mail` 升级至 `0.1.16`,`ias-service` 升级至 `0.1.25`,`ias-main` 升级至 `0.2.27`;已验证 `cargo fmt --check` 和 `cargo check`。 + + +- 修复服务队列记录 LLM 响应和工具结果预览时按字节直接切片的问题,避免中文、emoji 等多字节字符刚好落在预览上限时触发 `char boundary` panic。 +- 操作日志预览现在仍按字节上限控制长度,但会回退到合法 UTF-8 字符边界,保证写入 metadata 前不会破坏字符串。 +- 新增单元测试覆盖中文字符跨越 500 字节边界的预览场景。 +- 数据库无结构变化;新增 `0.2.26` 升级日志记录,`ias-service` 升级至 `0.1.23`,`ias-main` 升级至 `0.2.26`;已验证 `cargo fmt --check` 和 `cargo test -p ias-service`。 + +## 0.2.25 - 2026-07-08 + +- 在消息队列关键节点自动记录操作日志:收到渠道消息(channel)、LLM 响应(llm)、工具调用结果(tool)、上下文记录错误(error)和消息发送失败(error)。 +- 新增 `log_operation` 函数,支持按级别(info/warn/error)和模块写入日志。 +- `ias-service` 升级至 `0.1.22`,`ias-http` 升级至 `0.1.14`,`ias-main` 升级至 `0.2.25`;已验证 `cargo fmt`、`cargo check` 和 `cargo test`。 + +## 0.2.24 - 2026-07-08 + +- HTTP YAML 能力加载改为文件级容错:单个 YAML 配置读取或解析失败时只跳过该文件并返回 warning,不再导致其它 HTTP 能力全部为空。 +- `query_capabilities` 会继续展示成功加载的 HTTP 能力,并在 `warnings` 中标明被跳过的配置文件和错误原因,便于定位坏配置。 +- `call_capability` 调用 HTTP YAML 能力时同样继承文件级容错,坏配置不会影响其它可用 YAML 能力执行。 +- 数据库无结构变化;新增 `0.2.24` 升级日志记录,`ias-ai` 升级至 `0.1.13`,`ias-service` 升级至 `0.1.21`,`ias-main` 升级至 `0.2.24`;已验证 `cargo fmt` 和 `cargo test`。 + +## 0.2.23 - 2026-07-08 + +- 修复 `amap_geocode` HTTP 能力 YAML 的 `query` 配置格式,改为工具链引擎支持的二元组列表,避免 `query_capabilities` 因解析失败导致所有 HTTP 能力为空。 +- `query_capabilities` 在没有额外动态工具调用说明时,会明确提示固定工具调用约束已在系统提示词中生效,避免误解为缺少系统级约束。 +- 修正工具调用说明动态段中的过期能力名,不再引用已移除的 `record_tool_call_instruction`,改为提示通过 `query_api_docs` 查询 `context.tool-instructions.update` 后使用 `call_http_api` 维护。 +- 数据库无结构变化;新增 `0.2.23` 升级日志记录,`ias-ai` 升级至 `0.1.12`,`ias-context` 升级至 `0.1.5`,`ias-service` 升级至 `0.1.20`,`ias-main` 升级至 `0.2.23`;已验证 `cargo fmt` 和 `cargo test`。 + ## 0.2.22 - 2026-07-08 - AI API 能力去重:移除与 HTTP endpoint 一一重复的专用能力(如 web 页面、邮件查询、长期记忆和工具调用说明写入包装),统一改为 `query_api_docs` 发现接口、`call_http_api` 按 `endpoint_id` 调用接口。 diff --git a/Cargo.lock b/Cargo.lock index b8f7e34..aeaa2e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,7 +1042,7 @@ dependencies = [ [[package]] name = "ias-ai" -version = "0.1.11" +version = "0.1.14" dependencies = [ "anyhow", "chrono", @@ -1072,7 +1072,7 @@ dependencies = [ [[package]] name = "ias-context" -version = "0.1.4" +version = "0.1.6" dependencies = [ "anyhow", "axum", @@ -1084,9 +1084,17 @@ dependencies = [ "sqlx", ] +[[package]] +name = "ias-db" +version = "0.1.0" +dependencies = [ + "anyhow", + "sqlx", +] + [[package]] name = "ias-http" -version = "0.1.13" +version = "0.1.15" dependencies = [ "anyhow", "axum", @@ -1101,7 +1109,7 @@ dependencies = [ [[package]] name = "ias-mail" -version = "0.1.15" +version = "0.1.16" dependencies = [ "anyhow", "async-imap", @@ -1120,14 +1128,14 @@ dependencies = [ [[package]] name = "ias-main" -version = "0.2.22" +version = "0.2.27" dependencies = [ "anyhow", "clap", "dotenvy", + "ias-db", "ias-mail", "ias-service", - "sqlx", "tokio", "tracing", "tracing-subscriber", @@ -1135,7 +1143,7 @@ dependencies = [ [[package]] name = "ias-service" -version = "0.1.19" +version = "0.1.25" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5c9cab8..86f71c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["ias-main", "ias-service", "ias-ai", "ias-common", "ias-wechat", "ias-context", "ias-http", "ias-mail"] +members = ["ias-main", "ias-db", "ias-service", "ias-ai", "ias-common", "ias-wechat", "ias-context", "ias-http", "ias-mail"] resolver = "2" diff --git a/VERSION b/VERSION index 109a20f..b326984 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.22 +0.2.27 diff --git a/ias-ai/Cargo.toml b/ias-ai/Cargo.toml index f041162..2b29d07 100644 --- a/ias-ai/Cargo.toml +++ b/ias-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-ai" -version = "0.1.11" +version = "0.1.14" edition = "2024" [dependencies] diff --git a/ias-ai/src/toolkit.rs b/ias-ai/src/toolkit.rs index 22aedf2..dda9ac5 100644 --- a/ias-ai/src/toolkit.rs +++ b/ias-ai/src/toolkit.rs @@ -3,7 +3,7 @@ use crate::{ tools::http_api::call_http_api, tools::tool_instructions::query_tool_call_instruction, workflow::{ - core::{call_flow, load_configs}, + core::{call_flow, load_configs_with_warnings}, types::{FlowConfig, FlowParam}, }, }; @@ -135,7 +135,7 @@ async fn query_capabilities(channel: &ChannelMessage, tool_path: &str) -> String let mut result = serde_json::json!({ "api_capabilities": capability_briefs(&inventory.api_tools), "http_capabilities": capability_briefs(&inventory.http_tools), - "tool_call_instructions": tool_call_instructions.unwrap_or_else(|| "暂无工具调用说明。".to_string()), + "tool_call_instructions": tool_call_instructions.unwrap_or_else(|| "固定工具调用约束已在系统提示词中生效;当前没有额外动态工具调用说明。".to_string()), "total": inventory.api_tools.len() + inventory.http_tools.len(), "usage": "先用 query_capabilities 查看名称、描述和工具调用说明;需要参数时调用 query_capability_detail;执行时调用 call_capability。内部 HTTP API 统一通过 call_http_api 执行,先用 query_api_docs 查询 endpoint_id、参数和请求体。", }); @@ -293,17 +293,21 @@ pub async fn load_capability_inventory(tool_path: &str) -> CapabilityInventory { } let mut warnings = Vec::new(); - let http_tools = match load_configs(tool_path.to_string()).await { - Ok(configs) => configs - .into_iter() - .filter_map(|config| { - if seen.insert(config.name.clone()) { - Some(capability_from_flow_config(config, None)) - } else { - None - } - }) - .collect(), + let http_tools = match load_configs_with_warnings(tool_path.to_string()).await { + Ok(loaded) => { + warnings.extend(loaded.warnings); + loaded + .configs + .into_iter() + .filter_map(|config| { + if seen.insert(config.name.clone()) { + Some(capability_from_flow_config(config, None)) + } else { + None + } + }) + .collect() + } Err(error) => { warnings.push(format!("加载 HTTP 能力失败: {error}")); Vec::new() diff --git a/ias-ai/src/workflow/core.rs b/ias-ai/src/workflow/core.rs index db86be2..948bfd7 100644 --- a/ias-ai/src/workflow/core.rs +++ b/ias-ai/src/workflow/core.rs @@ -4,6 +4,12 @@ use tokio::fs; use crate::workflow::flow::start_flow; use crate::workflow::types::FlowConfig; +#[derive(Debug, Clone)] +pub struct LoadedFlowConfigs { + pub configs: Vec, + pub warnings: Vec, +} + pub async fn call_flow(path: String, args: String) -> String { let all_flow = match load_configs(path).await { Ok(flows) => flows, @@ -26,20 +32,27 @@ pub async fn call_flow(path: String, args: String) -> String { } pub async fn load_tools_define(path: String) -> anyhow::Result { - let configs = load_configs(path).await?; - Ok(serde_json::to_string(&configs).unwrap_or_default()) + let loaded = load_configs_with_warnings(path).await?; + Ok(serde_json::to_string(&loaded.configs).unwrap_or_default()) } pub async fn load_configs(path: String) -> anyhow::Result> { + Ok(load_configs_with_warnings(path).await?.configs) +} + +pub async fn load_configs_with_warnings(path: String) -> anyhow::Result { let mut configs: Vec = vec![]; + let mut warnings: Vec = vec![]; let config_files = scan_config_dir(path).await?; if !config_files.is_empty() { for file in config_files { - let config = load_yaml(file).await?; - configs.push(config); + match load_yaml(file.clone()).await { + Ok(config) => configs.push(config), + Err(error) => warnings.push(format!("跳过工具配置 {file}: {error}")), + } } } - Ok(configs) + Ok(LoadedFlowConfigs { configs, warnings }) } pub async fn load_yaml(path: String) -> anyhow::Result { @@ -82,3 +95,55 @@ async fn list_file() { println!(); } + +#[tokio::test] +async fn load_configs_skips_invalid_yaml_files() { + let dir = unique_test_dir("load_configs_skips_invalid_yaml_files"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + + tokio::fs::write( + dir.join("good.yaml"), + r#" +name: good_tool +desc: good config +params: [] +steps: [] +"#, + ) + .await + .unwrap(); + tokio::fs::write( + dir.join("bad.yaml"), + r#" +name: bad_tool +steps: + - query: + - key: value +"#, + ) + .await + .unwrap(); + + let loaded = load_configs_with_warnings(dir.display().to_string()) + .await + .unwrap(); + assert_eq!(loaded.configs.len(), 1); + assert_eq!(loaded.configs[0].name, "good_tool"); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("bad.yaml")); + + let configs = load_configs(dir.display().to_string()).await.unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "good_tool"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[cfg(test)] +fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("ias-ai-{name}-{}-{nanos}", std::process::id())) +} diff --git a/ias-context/Cargo.toml b/ias-context/Cargo.toml index 2b6ba7e..9210237 100644 --- a/ias-context/Cargo.toml +++ b/ias-context/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-context" -version = "0.1.4" +version = "0.1.6" edition = "2024" [dependencies] diff --git a/ias-context/src/manager.rs b/ias-context/src/manager.rs index 353d20c..05e5c47 100644 --- a/ias-context/src/manager.rs +++ b/ias-context/src/manager.rs @@ -37,7 +37,7 @@ pub fn tool_call_instruction_section(content: &str) -> Option { return None; } Some(format!( - "工具调用说明(动态,可由 AI 通过 record_tool_call_instruction 能力维护;只用于选择和组织工具调用,冲突时必须服从固定系统规则):\n{}", + "工具调用说明(动态,可由 AI 通过 query_api_docs 查询 context.tool-instructions.update 后,再用 call_http_api 维护;只用于选择和组织工具调用,冲突时必须服从固定系统规则):\n{}", trim_to_chars(content, 4_000) )) } @@ -900,6 +900,7 @@ fn read_i64_env(name: &str, default: i64) -> i64 { mod tests { use super::{ build_fixed_system_prompt, detect_assistant_name_preference, sanitize_tool_call_messages, + tool_call_instruction_section, }; use ias_common::model::ai::{ChatCompletionRequestMessage, ChatCompletionToolCall}; @@ -944,6 +945,16 @@ mod tests { assert!(prompt.contains("禁止臆造工具名或直接调用其它能力")); } + #[test] + fn tool_call_instruction_section_uses_current_update_path() { + let section = tool_call_instruction_section("先查文档再调用") + .expect("instruction section should render"); + + assert!(section.contains("context.tool-instructions.update")); + assert!(section.contains("call_http_api")); + assert!(!section.contains("record_tool_call_instruction")); + } + #[test] fn keeps_complete_multi_tool_call_sequence() { let messages = sanitize_tool_call_messages(vec![ diff --git a/ias-db/Cargo.toml b/ias-db/Cargo.toml new file mode 100644 index 0000000..ace698f --- /dev/null +++ b/ias-db/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ias-db" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +sqlx = { version = "0.9.0", features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "macros", + "migrate", + "chrono", +] } diff --git a/ias-service/build.rs b/ias-db/build.rs similarity index 75% rename from ias-service/build.rs rename to ias-db/build.rs index e2de2ed..ab438d6 100644 --- a/ias-service/build.rs +++ b/ias-db/build.rs @@ -11,14 +11,11 @@ fn main() { let path = entry.path(); if path.extension().is_some_and(|extension| extension == "sql") { println!("cargo:rerun-if-changed={}", path.display()); - // 将文件大小加入计数,确保新增/删除/修改文件都改变输出 count = count.wrapping_add(fs::metadata(&path).map(|m| m.len()).unwrap_or(0)); } } } - // 将 migration 摘要写入 OUT_DIR,lib.rs 通过 include! 引用。 - // 这样 cargo 可以追踪 migration 变更 → lib.rs 重编译 → sqlx::migrate! 重新展开。 let out_dir = std::env::var("OUT_DIR").unwrap(); let dest = Path::new(&out_dir).join("migration_stamp.rs"); fs::write( diff --git a/ias-service/migrations/20260625031750_create_account.sql b/ias-db/migrations/20260625031750_create_account.sql similarity index 100% rename from ias-service/migrations/20260625031750_create_account.sql rename to ias-db/migrations/20260625031750_create_account.sql diff --git a/ias-service/migrations/20260706000000_create_web_pages.sql b/ias-db/migrations/20260706000000_create_web_pages.sql similarity index 100% rename from ias-service/migrations/20260706000000_create_web_pages.sql rename to ias-db/migrations/20260706000000_create_web_pages.sql diff --git a/ias-service/migrations/20260706010000_context_management.sql b/ias-db/migrations/20260706010000_context_management.sql similarity index 100% rename from ias-service/migrations/20260706010000_context_management.sql rename to ias-db/migrations/20260706010000_context_management.sql diff --git a/ias-service/migrations/20260706011000_context_timestamps_timestamptz.sql b/ias-db/migrations/20260706011000_context_timestamps_timestamptz.sql similarity index 100% rename from ias-service/migrations/20260706011000_context_timestamps_timestamptz.sql rename to ias-db/migrations/20260706011000_context_timestamps_timestamptz.sql diff --git a/ias-service/migrations/20260706012000_normalize_context_scope.sql b/ias-db/migrations/20260706012000_normalize_context_scope.sql similarity index 100% rename from ias-service/migrations/20260706012000_normalize_context_scope.sql rename to ias-db/migrations/20260706012000_normalize_context_scope.sql diff --git a/ias-service/migrations/20260706013000_create_upgrade_logs.sql b/ias-db/migrations/20260706013000_create_upgrade_logs.sql similarity index 100% rename from ias-service/migrations/20260706013000_create_upgrade_logs.sql rename to ias-db/migrations/20260706013000_create_upgrade_logs.sql diff --git a/ias-service/migrations/20260706014000_insert_upgrade_log_0_1_3.sql b/ias-db/migrations/20260706014000_insert_upgrade_log_0_1_3.sql similarity index 100% rename from ias-service/migrations/20260706014000_insert_upgrade_log_0_1_3.sql rename to ias-db/migrations/20260706014000_insert_upgrade_log_0_1_3.sql diff --git a/ias-service/migrations/20260706015000_mail_polling.sql b/ias-db/migrations/20260706015000_mail_polling.sql similarity index 100% rename from ias-service/migrations/20260706015000_mail_polling.sql rename to ias-db/migrations/20260706015000_mail_polling.sql diff --git a/ias-service/migrations/20260706016000_mail_accounts_and_cache.sql b/ias-db/migrations/20260706016000_mail_accounts_and_cache.sql similarity index 100% rename from ias-service/migrations/20260706016000_mail_accounts_and_cache.sql rename to ias-db/migrations/20260706016000_mail_accounts_and_cache.sql diff --git a/ias-service/migrations/20260706017000_version_update_notifications.sql b/ias-db/migrations/20260706017000_version_update_notifications.sql similarity index 100% rename from ias-service/migrations/20260706017000_version_update_notifications.sql rename to ias-db/migrations/20260706017000_version_update_notifications.sql diff --git a/ias-service/migrations/20260706018000_translate_upgrade_logs_to_chinese.sql b/ias-db/migrations/20260706018000_translate_upgrade_logs_to_chinese.sql similarity index 100% rename from ias-service/migrations/20260706018000_translate_upgrade_logs_to_chinese.sql rename to ias-db/migrations/20260706018000_translate_upgrade_logs_to_chinese.sql diff --git a/ias-service/migrations/20260706019000_insert_upgrade_log_0_1_7.sql b/ias-db/migrations/20260706019000_insert_upgrade_log_0_1_7.sql similarity index 100% rename from ias-service/migrations/20260706019000_insert_upgrade_log_0_1_7.sql rename to ias-db/migrations/20260706019000_insert_upgrade_log_0_1_7.sql diff --git a/ias-service/migrations/20260706020000_add_module_versions.sql b/ias-db/migrations/20260706020000_add_module_versions.sql similarity index 100% rename from ias-service/migrations/20260706020000_add_module_versions.sql rename to ias-db/migrations/20260706020000_add_module_versions.sql diff --git a/ias-service/migrations/20260706021000_insert_upgrade_log_0_1_8.sql b/ias-db/migrations/20260706021000_insert_upgrade_log_0_1_8.sql similarity index 100% rename from ias-service/migrations/20260706021000_insert_upgrade_log_0_1_8.sql rename to ias-db/migrations/20260706021000_insert_upgrade_log_0_1_8.sql diff --git a/ias-service/migrations/20260706022000_insert_upgrade_log_0_1_9.sql b/ias-db/migrations/20260706022000_insert_upgrade_log_0_1_9.sql similarity index 100% rename from ias-service/migrations/20260706022000_insert_upgrade_log_0_1_9.sql rename to ias-db/migrations/20260706022000_insert_upgrade_log_0_1_9.sql diff --git a/ias-service/migrations/20260706023000_insert_upgrade_log_0_1_10.sql b/ias-db/migrations/20260706023000_insert_upgrade_log_0_1_10.sql similarity index 100% rename from ias-service/migrations/20260706023000_insert_upgrade_log_0_1_10.sql rename to ias-db/migrations/20260706023000_insert_upgrade_log_0_1_10.sql diff --git a/ias-service/migrations/20260706024000_insert_upgrade_log_0_1_11.sql b/ias-db/migrations/20260706024000_insert_upgrade_log_0_1_11.sql similarity index 100% rename from ias-service/migrations/20260706024000_insert_upgrade_log_0_1_11.sql rename to ias-db/migrations/20260706024000_insert_upgrade_log_0_1_11.sql diff --git a/ias-service/migrations/20260706025000_insert_upgrade_log_0_1_12.sql b/ias-db/migrations/20260706025000_insert_upgrade_log_0_1_12.sql similarity index 100% rename from ias-service/migrations/20260706025000_insert_upgrade_log_0_1_12.sql rename to ias-db/migrations/20260706025000_insert_upgrade_log_0_1_12.sql diff --git a/ias-service/migrations/20260706026000_insert_upgrade_log_0_1_13.sql b/ias-db/migrations/20260706026000_insert_upgrade_log_0_1_13.sql similarity index 100% rename from ias-service/migrations/20260706026000_insert_upgrade_log_0_1_13.sql rename to ias-db/migrations/20260706026000_insert_upgrade_log_0_1_13.sql diff --git a/ias-service/migrations/20260706027000_insert_upgrade_log_0_1_14.sql b/ias-db/migrations/20260706027000_insert_upgrade_log_0_1_14.sql similarity index 100% rename from ias-service/migrations/20260706027000_insert_upgrade_log_0_1_14.sql rename to ias-db/migrations/20260706027000_insert_upgrade_log_0_1_14.sql diff --git a/ias-service/migrations/20260706028000_insert_upgrade_log_0_1_15.sql b/ias-db/migrations/20260706028000_insert_upgrade_log_0_1_15.sql similarity index 100% rename from ias-service/migrations/20260706028000_insert_upgrade_log_0_1_15.sql rename to ias-db/migrations/20260706028000_insert_upgrade_log_0_1_15.sql diff --git a/ias-service/migrations/20260706029000_insert_upgrade_log_0_2_1.sql b/ias-db/migrations/20260706029000_insert_upgrade_log_0_2_1.sql similarity index 100% rename from ias-service/migrations/20260706029000_insert_upgrade_log_0_2_1.sql rename to ias-db/migrations/20260706029000_insert_upgrade_log_0_2_1.sql diff --git a/ias-service/migrations/20260706030000_insert_upgrade_log_0_2_0.sql b/ias-db/migrations/20260706030000_insert_upgrade_log_0_2_0.sql similarity index 100% rename from ias-service/migrations/20260706030000_insert_upgrade_log_0_2_0.sql rename to ias-db/migrations/20260706030000_insert_upgrade_log_0_2_0.sql diff --git a/ias-service/migrations/20260706031000_insert_upgrade_log_0_2_2.sql b/ias-db/migrations/20260706031000_insert_upgrade_log_0_2_2.sql similarity index 100% rename from ias-service/migrations/20260706031000_insert_upgrade_log_0_2_2.sql rename to ias-db/migrations/20260706031000_insert_upgrade_log_0_2_2.sql diff --git a/ias-service/migrations/20260707000000_insert_upgrade_log_0_2_3.sql b/ias-db/migrations/20260707000000_insert_upgrade_log_0_2_3.sql similarity index 100% rename from ias-service/migrations/20260707000000_insert_upgrade_log_0_2_3.sql rename to ias-db/migrations/20260707000000_insert_upgrade_log_0_2_3.sql diff --git a/ias-service/migrations/20260707001000_insert_upgrade_log_0_2_4.sql b/ias-db/migrations/20260707001000_insert_upgrade_log_0_2_4.sql similarity index 100% rename from ias-service/migrations/20260707001000_insert_upgrade_log_0_2_4.sql rename to ias-db/migrations/20260707001000_insert_upgrade_log_0_2_4.sql diff --git a/ias-service/migrations/20260707002000_insert_upgrade_log_0_2_5.sql b/ias-db/migrations/20260707002000_insert_upgrade_log_0_2_5.sql similarity index 100% rename from ias-service/migrations/20260707002000_insert_upgrade_log_0_2_5.sql rename to ias-db/migrations/20260707002000_insert_upgrade_log_0_2_5.sql diff --git a/ias-service/migrations/20260707003000_insert_upgrade_log_0_2_6.sql b/ias-db/migrations/20260707003000_insert_upgrade_log_0_2_6.sql similarity index 100% rename from ias-service/migrations/20260707003000_insert_upgrade_log_0_2_6.sql rename to ias-db/migrations/20260707003000_insert_upgrade_log_0_2_6.sql diff --git a/ias-service/migrations/20260707004000_insert_upgrade_log_0_2_7.sql b/ias-db/migrations/20260707004000_insert_upgrade_log_0_2_7.sql similarity index 100% rename from ias-service/migrations/20260707004000_insert_upgrade_log_0_2_7.sql rename to ias-db/migrations/20260707004000_insert_upgrade_log_0_2_7.sql diff --git a/ias-service/migrations/20260707005000_update_upgrade_log_0_2_7.sql b/ias-db/migrations/20260707005000_update_upgrade_log_0_2_7.sql similarity index 100% rename from ias-service/migrations/20260707005000_update_upgrade_log_0_2_7.sql rename to ias-db/migrations/20260707005000_update_upgrade_log_0_2_7.sql diff --git a/ias-service/migrations/20260707006000_add_mail_seen_status.sql b/ias-db/migrations/20260707006000_add_mail_seen_status.sql similarity index 100% rename from ias-service/migrations/20260707006000_add_mail_seen_status.sql rename to ias-db/migrations/20260707006000_add_mail_seen_status.sql diff --git a/ias-service/migrations/20260707007000_insert_upgrade_log_0_2_9.sql b/ias-db/migrations/20260707007000_insert_upgrade_log_0_2_9.sql similarity index 100% rename from ias-service/migrations/20260707007000_insert_upgrade_log_0_2_9.sql rename to ias-db/migrations/20260707007000_insert_upgrade_log_0_2_9.sql diff --git a/ias-service/migrations/20260707008000_insert_upgrade_log_0_2_10.sql b/ias-db/migrations/20260707008000_insert_upgrade_log_0_2_10.sql similarity index 100% rename from ias-service/migrations/20260707008000_insert_upgrade_log_0_2_10.sql rename to ias-db/migrations/20260707008000_insert_upgrade_log_0_2_10.sql diff --git a/ias-service/migrations/20260707009000_insert_upgrade_log_0_2_11.sql b/ias-db/migrations/20260707009000_insert_upgrade_log_0_2_11.sql similarity index 100% rename from ias-service/migrations/20260707009000_insert_upgrade_log_0_2_11.sql rename to ias-db/migrations/20260707009000_insert_upgrade_log_0_2_11.sql diff --git a/ias-service/migrations/20260707010000_insert_upgrade_log_0_2_12.sql b/ias-db/migrations/20260707010000_insert_upgrade_log_0_2_12.sql similarity index 100% rename from ias-service/migrations/20260707010000_insert_upgrade_log_0_2_12.sql rename to ias-db/migrations/20260707010000_insert_upgrade_log_0_2_12.sql diff --git a/ias-service/migrations/20260707011000_insert_upgrade_log_0_2_13.sql b/ias-db/migrations/20260707011000_insert_upgrade_log_0_2_13.sql similarity index 100% rename from ias-service/migrations/20260707011000_insert_upgrade_log_0_2_13.sql rename to ias-db/migrations/20260707011000_insert_upgrade_log_0_2_13.sql diff --git a/ias-service/migrations/20260707012000_insert_upgrade_log_0_2_14.sql b/ias-db/migrations/20260707012000_insert_upgrade_log_0_2_14.sql similarity index 100% rename from ias-service/migrations/20260707012000_insert_upgrade_log_0_2_14.sql rename to ias-db/migrations/20260707012000_insert_upgrade_log_0_2_14.sql diff --git a/ias-service/migrations/20260707013000_insert_upgrade_log_0_2_15.sql b/ias-db/migrations/20260707013000_insert_upgrade_log_0_2_15.sql similarity index 100% rename from ias-service/migrations/20260707013000_insert_upgrade_log_0_2_15.sql rename to ias-db/migrations/20260707013000_insert_upgrade_log_0_2_15.sql diff --git a/ias-service/migrations/20260707014000_insert_upgrade_log_0_2_16.sql b/ias-db/migrations/20260707014000_insert_upgrade_log_0_2_16.sql similarity index 100% rename from ias-service/migrations/20260707014000_insert_upgrade_log_0_2_16.sql rename to ias-db/migrations/20260707014000_insert_upgrade_log_0_2_16.sql diff --git a/ias-service/migrations/20260708001000_insert_upgrade_log_0_2_17.sql b/ias-db/migrations/20260708001000_insert_upgrade_log_0_2_17.sql similarity index 100% rename from ias-service/migrations/20260708001000_insert_upgrade_log_0_2_17.sql rename to ias-db/migrations/20260708001000_insert_upgrade_log_0_2_17.sql diff --git a/ias-service/migrations/20260708002000_add_additional_system_prompts.sql b/ias-db/migrations/20260708002000_add_additional_system_prompts.sql similarity index 100% rename from ias-service/migrations/20260708002000_add_additional_system_prompts.sql rename to ias-db/migrations/20260708002000_add_additional_system_prompts.sql diff --git a/ias-service/migrations/20260708003000_insert_upgrade_log_0_2_18.sql b/ias-db/migrations/20260708003000_insert_upgrade_log_0_2_18.sql similarity index 100% rename from ias-service/migrations/20260708003000_insert_upgrade_log_0_2_18.sql rename to ias-db/migrations/20260708003000_insert_upgrade_log_0_2_18.sql diff --git a/ias-service/migrations/20260708004000_context_raw_and_tool_instructions.sql b/ias-db/migrations/20260708004000_context_raw_and_tool_instructions.sql similarity index 100% rename from ias-service/migrations/20260708004000_context_raw_and_tool_instructions.sql rename to ias-db/migrations/20260708004000_context_raw_and_tool_instructions.sql diff --git a/ias-service/migrations/20260708005000_insert_upgrade_log_0_2_19.sql b/ias-db/migrations/20260708005000_insert_upgrade_log_0_2_19.sql similarity index 100% rename from ias-service/migrations/20260708005000_insert_upgrade_log_0_2_19.sql rename to ias-db/migrations/20260708005000_insert_upgrade_log_0_2_19.sql diff --git a/ias-service/migrations/20260708006000_insert_upgrade_log_0_2_20.sql b/ias-db/migrations/20260708006000_insert_upgrade_log_0_2_20.sql similarity index 100% rename from ias-service/migrations/20260708006000_insert_upgrade_log_0_2_20.sql rename to ias-db/migrations/20260708006000_insert_upgrade_log_0_2_20.sql diff --git a/ias-service/migrations/20260708007000_insert_upgrade_log_0_2_21.sql b/ias-db/migrations/20260708007000_insert_upgrade_log_0_2_21.sql similarity index 100% rename from ias-service/migrations/20260708007000_insert_upgrade_log_0_2_21.sql rename to ias-db/migrations/20260708007000_insert_upgrade_log_0_2_21.sql diff --git a/ias-service/migrations/20260708008000_insert_upgrade_log_0_2_22.sql b/ias-db/migrations/20260708008000_insert_upgrade_log_0_2_22.sql similarity index 100% rename from ias-service/migrations/20260708008000_insert_upgrade_log_0_2_22.sql rename to ias-db/migrations/20260708008000_insert_upgrade_log_0_2_22.sql diff --git a/ias-db/migrations/20260708009000_insert_upgrade_log_0_2_23.sql b/ias-db/migrations/20260708009000_insert_upgrade_log_0_2_23.sql new file mode 100644 index 0000000..f8d6dae --- /dev/null +++ b/ias-db/migrations/20260708009000_insert_upgrade_log_0_2_23.sql @@ -0,0 +1,34 @@ +INSERT INTO ias_upgrade_logs ( + version, + released_at, + title, + description, + changes, + modules +) VALUES ( + '0.2.23', + '2026-07-08T00:00:00+08:00', + '修复 HTTP 能力加载与工具说明措辞', + '修复高德地理编码 YAML 查询参数格式导致 HTTP 能力列表加载失败的问题,并同步修正工具调用说明中已过期的能力名称。', + '[ + "修复 amap_geocode HTTP 能力 YAML 的 query 配置格式,改为工具链引擎支持的二元组列表,避免 query_capabilities 因解析失败导致所有 HTTP 能力为空。", + "query_capabilities 在没有额外动态工具调用说明时,会明确提示固定工具调用约束已在系统提示词中生效,避免误解为缺少系统级约束。", + "修正工具调用说明动态段中的过期能力名,不再引用已移除的 record_tool_call_instruction,改为提示通过 query_api_docs 查询 context.tool-instructions.update 后使用 call_http_api 维护。", + "数据库无结构变化;已验证 cargo fmt 和 cargo test。" + ]'::jsonb, + '{ + "ias-common": "0.1.2", + "ias-ai": "0.1.12", + "ias-wechat": "0.1.1", + "ias-context": "0.1.5", + "ias-http": "0.1.13", + "ias-mail": "0.1.15", + "ias-service": "0.1.20", + "ias-main": "0.2.23" + }'::jsonb +) ON CONFLICT (version) DO UPDATE SET + released_at = EXCLUDED.released_at, + title = EXCLUDED.title, + description = EXCLUDED.description, + changes = EXCLUDED.changes, + modules = EXCLUDED.modules; diff --git a/ias-db/migrations/20260708009100_insert_upgrade_log_0_2_24.sql b/ias-db/migrations/20260708009100_insert_upgrade_log_0_2_24.sql new file mode 100644 index 0000000..0e071d0 --- /dev/null +++ b/ias-db/migrations/20260708009100_insert_upgrade_log_0_2_24.sql @@ -0,0 +1,34 @@ +INSERT INTO ias_upgrade_logs ( + version, + released_at, + title, + description, + changes, + modules +) VALUES ( + '0.2.24', + '2026-07-08T00:00:00+08:00', + 'HTTP YAML 能力加载文件级容错', + 'HTTP YAML 能力加载改为文件级容错,单个配置解析失败只会跳过该文件并返回 warning,不再影响其它 HTTP 能力展示和调用。', + '[ + "HTTP YAML 能力加载改为文件级容错:单个 YAML 配置读取或解析失败时只跳过该文件并返回 warning,不再导致其它 HTTP 能力全部为空。", + "query_capabilities 会继续展示成功加载的 HTTP 能力,并在 warnings 中标明被跳过的配置文件和错误原因,便于定位坏配置。", + "call_capability 调用 HTTP YAML 能力时同样继承文件级容错,坏配置不会影响其它可用 YAML 能力执行。", + "数据库无结构变化;已验证 cargo fmt 和 cargo test。" + ]'::jsonb, + '{ + "ias-common": "0.1.2", + "ias-ai": "0.1.13", + "ias-wechat": "0.1.1", + "ias-context": "0.1.5", + "ias-http": "0.1.13", + "ias-mail": "0.1.15", + "ias-service": "0.1.21", + "ias-main": "0.2.24" + }'::jsonb +) ON CONFLICT (version) DO UPDATE SET + released_at = EXCLUDED.released_at, + title = EXCLUDED.title, + description = EXCLUDED.description, + changes = EXCLUDED.changes, + modules = EXCLUDED.modules; diff --git a/ias-db/migrations/20260708010000_create_operation_logs.sql b/ias-db/migrations/20260708010000_create_operation_logs.sql new file mode 100644 index 0000000..d9e0d90 --- /dev/null +++ b/ias-db/migrations/20260708010000_create_operation_logs.sql @@ -0,0 +1,41 @@ +CREATE TABLE ias_operation_logs ( + id BIGSERIAL PRIMARY KEY, + level VARCHAR(32) NOT NULL DEFAULT 'info', + module VARCHAR(128) NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_ias_operation_logs_created + ON ias_operation_logs(created_at DESC); + +CREATE INDEX idx_ias_operation_logs_level + ON ias_operation_logs(level); + +CREATE INDEX idx_ias_operation_logs_module + ON ias_operation_logs(module); + +INSERT INTO ias_upgrade_logs ( + version, + released_at, + title, + description, + changes +) VALUES ( + '0.2.25', + '2026-07-08T00:00:00+08:00', + '操作日志入库与查询', + '新增操作日志表 ias_operation_logs,支持按级别、模块和关键词筛选日志,并新增日志查询页面。', + '[ + "新增 ias_operation_logs 数据库表,支持记录操作日志(级别、模块、消息、元数据和时间戳)。", + "新增 GET /v1/logs 日志查询接口,支持按 level、module、q 筛选和分页。", + "新增 GET /v1/logs/{id} 单条日志查询接口。", + "新增 React 日志查询页面,支持按级别、模块和关键词筛选,分页浏览。", + "导航栏新增「日志」入口。" + ]'::jsonb +) ON CONFLICT (version) DO UPDATE SET + released_at = EXCLUDED.released_at, + title = EXCLUDED.title, + description = EXCLUDED.description, + changes = EXCLUDED.changes; \ No newline at end of file diff --git a/ias-db/migrations/20260708010100_insert_upgrade_log_0_2_26.sql b/ias-db/migrations/20260708010100_insert_upgrade_log_0_2_26.sql new file mode 100644 index 0000000..bd37bfb --- /dev/null +++ b/ias-db/migrations/20260708010100_insert_upgrade_log_0_2_26.sql @@ -0,0 +1,34 @@ +INSERT INTO ias_upgrade_logs ( + version, + released_at, + title, + description, + changes, + modules +) VALUES ( + '0.2.26', + '2026-07-08T00:00:00+08:00', + '修复操作日志预览 UTF-8 截断', + '修复服务队列记录 LLM 响应和工具结果预览时按字节直接切片导致中文等多字节字符触发 char boundary panic 的问题。', + '[ + "修复服务队列记录 LLM 响应和工具结果预览时按字节直接切片的问题,避免中文、emoji 等多字节字符刚好落在预览上限时触发 char boundary panic。", + "操作日志预览现在仍按字节上限控制长度,但会回退到合法 UTF-8 字符边界,保证写入 metadata 前不会破坏字符串。", + "新增单元测试覆盖中文字符跨越 500 字节边界的预览场景。", + "数据库无结构变化;已验证 cargo fmt --check 和 cargo test -p ias-service。" + ]'::jsonb, + '{ + "ias-common": "0.1.2", + "ias-ai": "0.1.13", + "ias-wechat": "0.1.1", + "ias-context": "0.1.5", + "ias-http": "0.1.14", + "ias-mail": "0.1.15", + "ias-service": "0.1.23", + "ias-main": "0.2.26" + }'::jsonb +) ON CONFLICT (version) DO UPDATE SET + released_at = EXCLUDED.released_at, + title = EXCLUDED.title, + description = EXCLUDED.description, + changes = EXCLUDED.changes, + modules = EXCLUDED.modules; diff --git a/ias-db/migrations/20260708011000_insert_upgrade_log_0_2_27.sql b/ias-db/migrations/20260708011000_insert_upgrade_log_0_2_27.sql new file mode 100644 index 0000000..dde74d6 --- /dev/null +++ b/ias-db/migrations/20260708011000_insert_upgrade_log_0_2_27.sql @@ -0,0 +1,42 @@ +INSERT INTO ias_upgrade_logs ( + version, + released_at, + title, + description, + changes, + modules +) VALUES ( + '0.2.27', + '2026-07-08T00:00:00+08:00', + '抽离数据库基础设施模块', + '新增 ias-db 模块集中负责数据库连接和 SQLx 迁移执行,并将原 ias-service/migrations 整体迁移到 ias-db/migrations。同时新增操作日志查询接口与前端页面,邮件远程已读同步,以及多处改进。', + '[ + "新增 ias-db crate,集中负责 PostgreSQL 连接池创建、DATABASE_URL 读取和 SQLx 迁移执行。", + "将原 ias-service/migrations 整体迁移到 ias-db/migrations,迁移嵌入和变更监听由 ias-db/build.rs 负责。", + "ias-main 改为直接调用 ias_db::connect_db(),ias-service 只接收已建立的 PgPool 并运行服务,数据库生命周期边界更清晰。", + "新增操作日志 HTTP 查询接口 GET /v1/logs 和 GET /v1/logs/{id},支持按级别、模块和关键词筛选,API 文档新增 logs 功能分组。", + "新增操作日志 Web 前端页面,支持按级别、模块和关键词筛选查看日志,便于排障和审计。", + "消息队列关键节点增加操作日志写入:LLM 响应、渠道消息接收、上下文记录错误、消息发送失败等场景自动记录到 ias_operation_logs。", + "ias-mail 新增 mark_remote_message_seen 函数,通过 IMAP 设置邮件已读并验证服务器确认,抽取 connect_imap_session 复用连接逻辑。", + "工作流配置加载改为文件级容错:ias-ai 新增 load_configs_with_warnings,返回 LoadedFlowConfigs 包含 warnings 信息,单个 YAML 配置损坏时不会导致所有 HTTP 能力为空。", + "ias-context 工具调用说明中的能力引用路径从已废弃的 record_tool_call_instruction 更新为 query_api_docs + call_http_api,并新增单元测试验证。", + "新增高德地图地理编码/逆地理编码 HTTP 能力配置文件 tools/amap_geocode.yaml。", + "数据库无结构变化;已验证 cargo fmt --check 和 cargo check。" + ]'::jsonb, + '{ + "ias-common": "0.1.2", + "ias-ai": "0.1.14", + "ias-wechat": "0.1.1", + "ias-context": "0.1.6", + "ias-http": "0.1.15", + "ias-mail": "0.1.16", + "ias-db": "0.1.0", + "ias-service": "0.1.25", + "ias-main": "0.2.27" + }'::jsonb +) ON CONFLICT (version) DO UPDATE SET + released_at = EXCLUDED.released_at, + title = EXCLUDED.title, + description = EXCLUDED.description, + changes = EXCLUDED.changes, + modules = EXCLUDED.modules; diff --git a/ias-db/src/lib.rs b/ias-db/src/lib.rs new file mode 100644 index 0000000..da1bda4 --- /dev/null +++ b/ias-db/src/lib.rs @@ -0,0 +1,27 @@ +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::env; + +include!(concat!(env!("OUT_DIR"), "/migration_stamp.rs")); + +pub type DbPool = PgPool; + +const DEFAULT_MAX_CONNECTIONS: u32 = 10; + +pub async fn connect_db() -> anyhow::Result { + let db_url = env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("必须设置DATABASE_URL"))?; + connect_db_url(&db_url).await +} + +pub async fn connect_db_url(db_url: &str) -> anyhow::Result { + let pool = PgPoolOptions::new() + .max_connections(DEFAULT_MAX_CONNECTIONS) + .connect(db_url) + .await?; + run_migrations(&pool).await?; + Ok(pool) +} + +pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> { + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} diff --git a/ias-http/Cargo.toml b/ias-http/Cargo.toml index 2d96f02..6cb93ec 100644 --- a/ias-http/Cargo.toml +++ b/ias-http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-http" -version = "0.1.13" +version = "0.1.15" edition = "2024" [dependencies] diff --git a/ias-http/src/api_docs.rs b/ias-http/src/api_docs.rs index 1275fdc..ea8ebdc 100644 --- a/ias-http/src/api_docs.rs +++ b/ias-http/src/api_docs.rs @@ -59,9 +59,9 @@ async fn list_api_docs(Query(query): Query) -> Json { .filter(|doc| { group .as_deref() - .map_or(true, |group| doc.group.eq_ignore_ascii_case(group)) + .is_none_or(|group| doc.group.eq_ignore_ascii_case(group)) }) - .filter(|doc| q.as_deref().map_or(true, |q| doc_or_group_matches(doc, q))) + .filter(|doc| q.as_deref().is_none_or(|q| doc_or_group_matches(doc, q))) .collect::>(); let total = docs.len(); let matched_groups = docs.iter().map(|doc| doc.group).collect::>(); @@ -70,10 +70,10 @@ async fn list_api_docs(Query(query): Query) -> Json { .filter(|api_group| { group .as_deref() - .map_or(true, |group| api_group.id.eq_ignore_ascii_case(group)) + .is_none_or(|group| api_group.id.eq_ignore_ascii_case(group)) }) .filter(|api_group| { - q.as_deref().map_or(true, |q| group_matches(api_group, q)) + q.as_deref().is_none_or(|q| group_matches(api_group, q)) || matched_groups.contains(api_group.id) }) .collect::>(); @@ -204,6 +204,21 @@ fn api_groups() -> Vec { "scope", ], }, + ApiGroupDoc { + id: "logs", + title: "操作日志", + description: "用于查询系统操作日志,支持按级别、模块和关键词筛选,帮助排障和审计。", + aspects: &[ + "日志列表", + "日志筛选", + "单条日志详情", + "按级别过滤", + "按模块过滤", + ], + keywords: &[ + "log", "日志", "操作", "审计", "级别", "error", "warn", "info", + ], + }, ] } @@ -562,6 +577,40 @@ fn api_docs() -> Vec { response: r#"{"success":true,"has_query":true,"scope_key":"wechat:...","tools":[...],"default_system_prompt":"...","tool_call_instruction":"...","default_messages":[...],"sessions":[...],"messages":[...],"memories":[...],"summaries":[...]}"#, notes: &["未提供 account_id/from_user_id 时只返回默认调试数据。"], }, + ApiDoc { + id: "logs.list", + group: "logs", + method: "GET", + path: "/v1/logs", + summary: "查询操作日志列表", + description: "分页查询 ias_operation_logs 中的操作日志,支持按 level、module 和关键词 q 筛选。", + auth: "无", + query_params: &[ + "level: 可选日志级别,例如 info、warn、error", + "module: 可选模块名", + "q: 可选关键词,匹配 message 和 metadata", + "page: 可选页码,默认 1", + "per_page: 可选每页数量,默认 20,范围 1..100", + ], + path_params: &[], + body: None, + response: r#"{"success":true,"page":1,"per_page":20,"total":1,"logs":[{"id":1,"level":"info","module":"http","message":"...","metadata":{},"created_at":"..."}]}"#, + notes: &["时间倒序排列,用于排障和审计。"], + }, + ApiDoc { + id: "logs.get", + group: "logs", + method: "GET", + path: "/v1/logs/{id}", + summary: "查询单条操作日志", + description: "根据 id 返回单条操作日志的完整信息。", + auth: "无", + query_params: &[], + path_params: &["id: 日志记录 id"], + body: None, + response: r#"{"success":true,"log":{"id":1,"level":"info","module":"http","message":"...","metadata":{},"created_at":"..."}}"#, + notes: &["日志不存在时返回 404。"], + }, ] } @@ -598,7 +647,7 @@ fn doc_matches(doc: &ApiDoc, query: &str) -> bool { fn doc_or_group_matches(doc: &ApiDoc, query: &str) -> bool { doc_matches(doc, query) - || api_group(doc.group).map_or(false, |group| group_matches(&group, query)) + || api_group(doc.group).is_some_and(|group| group_matches(&group, query)) } fn group_matches(group: &ApiGroupDoc, query: &str) -> bool { diff --git a/ias-http/src/app.rs b/ias-http/src/app.rs index 9583590..e971670 100644 --- a/ias-http/src/app.rs +++ b/ias-http/src/app.rs @@ -7,6 +7,7 @@ use tower_http::cors::{Any, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; use crate::api_docs::routes as api_doc_routes; +use crate::logs::routes as log_routes; use crate::updates::routes as update_routes; use crate::web::routes as web_routes; @@ -26,6 +27,7 @@ pub async fn create_http( let app = Router::new() .route("/v1/health", get(health)) .merge(api_doc_routes::<()>()) + .merge(log_routes::<()>(db.clone())) .merge(update_routes::<()>(db.clone())) .merge(web_routes::routes::<()>(db)) .merge(extra_routes) diff --git a/ias-http/src/lib.rs b/ias-http/src/lib.rs index 68c15bd..b30afd6 100644 --- a/ias-http/src/lib.rs +++ b/ias-http/src/lib.rs @@ -1,5 +1,6 @@ pub mod api_docs; pub mod app; +pub mod logs; pub mod updates; pub mod web; diff --git a/ias-http/src/logs.rs b/ias-http/src/logs.rs new file mode 100644 index 0000000..b616a15 --- /dev/null +++ b/ias-http/src/logs.rs @@ -0,0 +1,410 @@ +use axum::extract::{Path, Query}; +use axum::http::StatusCode; +use axum::routing::get; +use axum::{Extension, Json, Router}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sqlx::PgPool; +use sqlx::prelude::FromRow; + +type ApiError = (StatusCode, Json); + +#[derive(Clone)] +struct LogsRouteState { + db: PgPool, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct OperationLog { + pub id: i64, + pub level: String, + pub module: String, + pub message: String, + pub metadata: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct LogListQuery { + pub level: Option, + pub module: Option, + pub q: Option, + pub page: Option, + pub per_page: Option, +} + +// ── 路由 ──────────────────────────────────────────────── + +pub fn routes(db: PgPool) -> Router +where + S: Clone + Send + Sync + 'static, +{ + Router::new() + .route("/v1/logs", get(list_logs)) + .route("/v1/logs/{id}", get(get_log)) + .layer(Extension(LogsRouteState { db })) +} + +// ── JSON API ───────────────────────────────────────────── + +async fn list_logs( + Extension(state): Extension, + Query(query): Query, +) -> Result, ApiError> { + let level = optional_text(query.level); + let module = optional_text(query.module); + let q = optional_text(query.q); + let per_page = query.per_page.unwrap_or(20).clamp(1, 100); + let page = query.page.unwrap_or(1).max(1); + let offset = (page - 1) * per_page; + + let (logs, total) = query_logs( + &state.db, + level.as_deref(), + module.as_deref(), + q.as_deref(), + per_page, + offset, + ) + .await + .map_err(api_internal_error)?; + + Ok(Json(json!({ + "success": true, + "page": page, + "per_page": per_page, + "total": total, + "logs": logs, + }))) +} + +async fn get_log( + Extension(state): Extension, + Path(id): Path, +) -> Result, ApiError> { + let log = find_log(&state.db, id) + .await + .map_err(api_internal_error)? + .ok_or_else(|| api_not_found("日志不存在"))?; + + Ok(Json(json!({ + "success": true, + "log": log, + }))) +} + +// ── 数据库查询 ────────────────────────────────────────── + +async fn query_logs( + pool: &PgPool, + level: Option<&str>, + module: Option<&str>, + q: Option<&str>, + limit: i64, + offset: i64, +) -> anyhow::Result<(Vec, i64)> { + let query_pattern = q.map(|value| format!("%{}%", value)); + + let total: i64 = match (level, module, query_pattern.as_deref()) { + (Some(lv), Some(mod_), Some(q)) => { + sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM ias_operation_logs + WHERE level = $1 + AND module = $2 + AND (message ILIKE $3 OR metadata::text ILIKE $3) + "#, + ) + .bind(lv) + .bind(mod_) + .bind(q) + .fetch_one(pool) + .await? + } + (Some(lv), Some(mod_), None) => { + sqlx::query_scalar( + r#"SELECT COUNT(*) FROM ias_operation_logs WHERE level = $1 AND module = $2"#, + ) + .bind(lv) + .bind(mod_) + .fetch_one(pool) + .await? + } + (Some(lv), None, Some(q)) => { + sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM ias_operation_logs + WHERE level = $1 + AND (message ILIKE $2 OR metadata::text ILIKE $2) + "#, + ) + .bind(lv) + .bind(q) + .fetch_one(pool) + .await? + } + (Some(lv), None, None) => { + sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_operation_logs WHERE level = $1"#) + .bind(lv) + .fetch_one(pool) + .await? + } + (None, Some(mod_), Some(q)) => { + sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM ias_operation_logs + WHERE module = $1 + AND (message ILIKE $2 OR metadata::text ILIKE $2) + "#, + ) + .bind(mod_) + .bind(q) + .fetch_one(pool) + .await? + } + (None, Some(mod_), None) => { + sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_operation_logs WHERE module = $1"#) + .bind(mod_) + .fetch_one(pool) + .await? + } + (None, None, Some(q)) => { + sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM ias_operation_logs + WHERE message ILIKE $1 OR metadata::text ILIKE $1 + "#, + ) + .bind(q) + .fetch_one(pool) + .await? + } + (None, None, None) => { + sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_operation_logs"#) + .fetch_one(pool) + .await? + } + }; + + let rows = match (level, module, query_pattern.as_deref()) { + (Some(lv), Some(mod_), Some(q)) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE level = $1 + AND module = $2 + AND (message ILIKE $3 OR metadata::text ILIKE $3) + ORDER BY created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(lv) + .bind(mod_) + .bind(q) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (Some(lv), Some(mod_), None) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE level = $1 AND module = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(lv) + .bind(mod_) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (Some(lv), None, Some(q)) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE level = $1 + AND (message ILIKE $2 OR metadata::text ILIKE $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(lv) + .bind(q) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (Some(lv), None, None) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE level = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(lv) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (None, Some(mod_), Some(q)) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE module = $1 + AND (message ILIKE $2 OR metadata::text ILIKE $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(mod_) + .bind(q) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (None, Some(mod_), None) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE module = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(mod_) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (None, None, Some(q)) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE message ILIKE $1 OR metadata::text ILIKE $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(q) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + + (None, None, None) => { + sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } + }; + + Ok((rows, total)) +} + +async fn find_log(pool: &PgPool, id: i64) -> anyhow::Result> { + let row = sqlx::query_as::<_, OperationLog>( + r#" + SELECT id, level, module, message, metadata, created_at + FROM ias_operation_logs + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row) +} + +// ── 辅助函数 ───────────────────────────────────────────── + +fn optional_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn api_not_found(message: impl Into) -> ApiError { + ( + StatusCode::NOT_FOUND, + Json(json!({ + "success": false, + "message": message.into(), + })), + ) +} + +fn api_internal_error(error: anyhow::Error) -> ApiError { + eprintln!("日志查询接口失败: {error}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "success": false, + "message": "日志查询接口失败", + })), + ) +} + +pub async fn log_operation( + pool: &PgPool, + level: &str, + module: &str, + message: &str, + metadata: Value, +) { + let level = level.trim().to_ascii_lowercase(); + let level = if level.is_empty() { "info" } else { &level }; + let result = sqlx::query( + r#" + INSERT INTO ias_operation_logs (level, module, message, metadata) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(level) + .bind(module.trim()) + .bind(message.trim()) + .bind(&metadata) + .execute(pool) + .await; + + if let Err(err) = result { + eprintln!("写入操作日志失败: {err}"); + } +} diff --git a/ias-http/src/web/utils.rs b/ias-http/src/web/utils.rs index 65b806e..435bef5 100644 --- a/ias-http/src/web/utils.rs +++ b/ias-http/src/web/utils.rs @@ -75,7 +75,7 @@ fn configured_internal_url() -> String { .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) .map(|value| value.replace("0.0.0.0", "127.0.0.1")) - .unwrap_or_else(|| configured_base_url()) + .unwrap_or_else(configured_base_url) } fn configured_base_url_from_env() -> Option { diff --git a/ias-mail/Cargo.toml b/ias-mail/Cargo.toml index f48affe..47c890c 100644 --- a/ias-mail/Cargo.toml +++ b/ias-mail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-mail" -version = "0.1.15" +version = "0.1.16" edition = "2024" [dependencies] diff --git a/ias-mail/src/poller.rs b/ias-mail/src/poller.rs index 6e3ba9b..b4988c7 100644 --- a/ias-mail/src/poller.rs +++ b/ias-mail/src/poller.rs @@ -171,28 +171,37 @@ pub async fn test_connection(config: MailPollerConfig) -> anyhow::Result<()> { test_connection_async(&config).await } +pub async fn mark_remote_message_seen(config: &MailPollerConfig, uid: &str) -> anyhow::Result<()> { + let uid = normalized_imap_uid(uid)?; + let mut session = connect_imap_session(config).await?; + session + .select(&config.mailbox) + .await + .with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?; + let _: Vec = session + .uid_store(uid, "+FLAGS.SILENT (\\Seen)") + .await + .with_context(|| format!("设置邮件已读失败 uid={uid}"))? + .try_collect() + .await + .with_context(|| format!("确认邮件已读同步失败 uid={uid}"))?; + let fetches: Vec = session + .uid_fetch(uid, "(FLAGS)") + .await + .with_context(|| format!("读取邮件已读状态失败 uid={uid}"))? + .try_collect() + .await + .with_context(|| format!("确认邮件已读状态失败 uid={uid}"))?; + let seen = fetches.iter().any(is_seen); + let _ = session.logout().await; + if !seen { + anyhow::bail!("服务器未确认邮件已读 uid={uid}"); + } + Ok(()) +} + async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result> { - let addr = format!("{}:{}", config.host, config.port); - let tcp_stream = TcpStream::connect(&addr) - .await - .with_context(|| format!("连接 IMAP 服务器失败: {addr}"))?; - let native_tls = native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(config.accept_invalid_certs) - .build()?; - - let tls = tokio_native_tls::TlsConnector::from(native_tls); - let tls_stream = tls - .connect(&config.host, tcp_stream) - .await - .with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?; - - let client = async_imap::Client::new(tls_stream); - let mut session = client - .login(&config.username, &config.password) - .await - .map_err(|(error, _)| error) - .context("IMAP 登录失败")?; - + let mut session = connect_imap_session(config).await?; let mailbox = session .select(&config.mailbox) .await @@ -233,6 +242,14 @@ async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result anyhow::Result<&str> { + let uid = uid.trim(); + if uid.is_empty() || !uid.bytes().all(|byte| byte.is_ascii_digit()) { + anyhow::bail!("邮件 UID 无法用于 IMAP 同步: {uid}"); + } + Ok(uid) +} + fn recent_sequence_set(exists: u32, limit: u32) -> Option { if exists == 0 || limit == 0 { return None; @@ -251,6 +268,18 @@ fn is_seen(fetch: &Fetch) -> bool { } async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> { + let mut session = connect_imap_session(config).await?; + session + .select(&config.mailbox) + .await + .with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?; + let _ = session.logout().await; + Ok(()) +} + +async fn connect_imap_session( + config: &MailPollerConfig, +) -> anyhow::Result>> { let addr = format!("{}:{}", config.host, config.port); let tcp_stream = TcpStream::connect(&addr) .await @@ -267,18 +296,12 @@ async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> .with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?; let client = async_imap::Client::new(tls_stream); - let mut session = client + let session = client .login(&config.username, &config.password) .await .map_err(|(error, _)| error) .context("IMAP 登录失败")?; - - session - .select(&config.mailbox) - .await - .with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?; - let _ = session.logout().await; - Ok(()) + Ok(session) } fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result { @@ -506,8 +529,9 @@ fn split_headers(raw: &str) -> &str { #[cfg(test)] mod tests { use super::{ - AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, parse_message, - preview_text, recent_sequence_set, should_skip_fetched_message, summarize_mail, + AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, normalized_imap_uid, + parse_message, preview_text, recent_sequence_set, should_skip_fetched_message, + summarize_mail, }; #[test] @@ -530,6 +554,15 @@ mod tests { assert!(!should_skip_fetched_message(false, true)); } + #[test] + fn accepts_only_numeric_imap_uid_for_server_mutation() { + assert_eq!(normalized_imap_uid("123").unwrap(), "123"); + assert_eq!(normalized_imap_uid(" 123 ").unwrap(), "123"); + assert!(normalized_imap_uid("").is_err()); + assert!(normalized_imap_uid("seq-1").is_err()); + assert!(normalized_imap_uid("1:*").is_err()); + } + #[test] fn reads_folded_header() { let raw = b"From: Alice\r\n \r\nSubject: Hi\r\n\r\nBody"; diff --git a/ias-mail/src/routes.rs b/ias-mail/src/routes.rs index 74b1bb2..dd3187f 100644 --- a/ias-mail/src/routes.rs +++ b/ias-mail/src/routes.rs @@ -5,7 +5,9 @@ use axum::{Extension, Json, Router}; use serde_json::{Value, json}; use sqlx::PgPool; -use crate::model::MailListQuery; +use crate::config::MailPollerConfig; +use crate::model::{MailListQuery, MailMessage}; +use crate::poller::mark_remote_message_seen; use crate::repository::MailRepository; type ApiError = (StatusCode, Json); @@ -60,12 +62,11 @@ async fn get_message( .map_err(api_internal_error)? .ok_or_else(|| api_not_found("邮件记录不存在"))?; - // 标记已读 if !message.seen { - match MailRepository::mark_as_seen(&state.db, id).await { + match mark_message_seen(&state.db, &message).await { Ok(true) => message.seen = true, Ok(false) => {} - Err(error) => eprintln!("标记邮件已读失败 id={id}: {error}"), + Err(error) => eprintln!("同步邮件已读失败 id={id}: {error:#}"), } } @@ -77,6 +78,35 @@ async fn get_message( // ── 辅助函数 ───────────────────────────────────────────── +async fn mark_message_seen(pool: &PgPool, message: &MailMessage) -> anyhow::Result { + let mut config = mail_config_for_message(pool, message) + .await? + .ok_or_else(|| anyhow::anyhow!("没有可用于同步已读状态的邮件账户配置"))?; + config.mailbox = message.mailbox.clone(); + mark_remote_message_seen(&config, &message.uid).await?; + MailRepository::mark_as_seen(pool, message.id).await +} + +async fn mail_config_for_message( + pool: &PgPool, + message: &MailMessage, +) -> anyhow::Result> { + if let Some(account_id) = message.mail_account_id { + let account = MailRepository::find_account(pool, account_id) + .await? + .ok_or_else(|| anyhow::anyhow!("邮件账户不存在: {account_id}"))?; + return Ok(Some(MailPollerConfig::from_account(account))); + } + + let Some(config) = MailPollerConfig::from_env()? else { + return Ok(None); + }; + if config.source_key == message.source_key { + return Ok(Some(config)); + } + Ok(None) +} + fn api_not_found(message: impl Into) -> ApiError { ( StatusCode::NOT_FOUND, diff --git a/ias-main/Cargo.toml b/ias-main/Cargo.toml index 5e73c5c..2c4a6d1 100644 --- a/ias-main/Cargo.toml +++ b/ias-main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-main" -version = "0.2.22" +version = "0.2.27" edition = "2024" [[bin]] @@ -8,17 +8,11 @@ name = "ias" path = "src/main.rs" [dependencies] +ias-db = { path = "../ias-db" } ias-service = { path = "../ias-service" } ias-mail = { path = "../ias-mail" } clap = { version = "4.6.1", features = ["derive"] } anyhow = "1" -sqlx = { version = "0.9.0", features = [ - "runtime-tokio", - "tls-rustls", - "postgres", - "migrate", - "chrono", -] } tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } dotenvy = "0.15.7" tracing = "0.1" diff --git a/ias-main/src/main.rs b/ias-main/src/main.rs index 73f2ff5..85de942 100644 --- a/ias-main/src/main.rs +++ b/ias-main/src/main.rs @@ -2,12 +2,12 @@ mod cli; use clap::Parser; use cli::{Channels, Cli, Commands, MailAction, ServiceAction, WechatAction}; +use ias_db::connect_db; use ias_service::supervisor::{ print_logs, restart_service, start_service, status_service, stop_service, }; use ias_service::{ - connect_db, delete_account, init_logging, list_accounts, login_user, rename_account, - run_service, + delete_account, init_logging, list_accounts, login_user, rename_account, run_service, }; #[tokio::main] diff --git a/ias-service/Cargo.toml b/ias-service/Cargo.toml index fef4760..f7642ac 100644 --- a/ias-service/Cargo.toml +++ b/ias-service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-service" -version = "0.1.19" +version = "0.1.25" edition = "2024" [dependencies] @@ -46,8 +46,6 @@ sqlx = { version = "0.9.0", features = [ "runtime-tokio", "tls-rustls", "postgres", - "macros", - "migrate", "chrono", ] } # ── 错误处理 ── diff --git a/ias-service/src/core/queue.rs b/ias-service/src/core/queue.rs index cf5889c..fd5d21b 100644 --- a/ias-service/src/core/queue.rs +++ b/ias-service/src/core/queue.rs @@ -2,7 +2,10 @@ use ias_ai::core::send_message; use ias_common::model::ai::ChatCompletionRequestMessage; use ias_common::queue::{ChannelMessage, QueueMessage, ToolMessage}; use ias_context::{ContextManager, NewSessionReason}; +use ias_http::logs::log_operation; use ias_wechat::manager::WeChatMultiAccountManager; +use serde_json::json; +use sqlx::PgPool; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -20,6 +23,7 @@ pub async fn create_queue( mut receiver: Receiver, manager: Arc, context_manager: ContextManager, + pool: PgPool, ) -> anyhow::Result> { let handle = tokio::spawn(async move { let mut typing_sessions: HashMap = HashMap::new(); @@ -46,6 +50,20 @@ pub async fn create_queue( assistant.tool_calls.as_ref().map_or(0, Vec::len) ); + log_operation( + &pool, + "info", + "llm", + &format!("LLM 响应 channel={} account_id={} from_user_id={} 工具调用数={}", channel.channel, channel.account_id, channel.from_user_id, assistant.tool_calls.as_ref().map_or(0, Vec::len)), + json!({ + "content_preview": text_preview(&content, 500), + "reasoning_preview": text_preview(&reasoning, 500), + "tool_call_count": assistant.tool_calls.as_ref().map_or(0, Vec::len), + "tool_call_names": assistant.tool_calls.as_ref().map(|calls| calls.iter().filter_map(|c| c.function.as_ref().map(|f| f.name.clone())).collect::>()), + }), + ) + .await; + if has_tool_calls { refresh_typing(manager.clone(), &typing_sessions, &channel).await; } else if !content.trim().is_empty() || !reasoning.trim().is_empty() { @@ -106,6 +124,14 @@ pub async fn create_queue( .await { eprintln!("记录 assistant 上下文失败: {}", err); + log_operation( + &pool, + "error", + "context", + &format!("记录 assistant 上下文失败 channel={} account_id={} from_user_id={}", channel.channel, channel.account_id, channel.from_user_id), + json!({"error": err.to_string()}), + ) + .await; } if !has_tool_calls { let key = operation_key(&channel); @@ -140,6 +166,21 @@ pub async fn create_queue( bundle.from_user_id.clone(), bundle.content.clone() ); + log_operation( + &pool, + "info", + "channel", + &format!( + "收到渠道消息 channel={} account_id={} from_user_id={}", + bundle.channel, bundle.account_id, bundle.from_user_id + ), + json!({ + "content": bundle.content.clone(), + "context": bundle.context.clone(), + "external_message_id": bundle.external_message_id.clone(), + }), + ) + .await; let op_key = operation_key(&bundle); if bundle.content.trim().eq_ignore_ascii_case("/new") { if let Some(task) = active_tasks.remove(&op_key) { @@ -179,15 +220,35 @@ pub async fn create_queue( Ok(snapshot) => snapshot.messages, Err(err) => { eprintln!("记录 user 上下文失败,退回单轮消息: {}", err); + log_operation( + &pool, + "error", + "context", + &format!( + "记录 user 上下文失败 channel={} account_id={} from_user_id={}", + bundle.channel, bundle.account_id, bundle.from_user_id + ), + json!({"error": err.to_string()}), + ) + .await; vec![ChatCompletionRequestMessage::new( "user".to_string(), bundle.content.clone(), )] } }; + let pool_clone = pool.clone(); let task = tokio::spawn(async move { if let Err(err) = send_message(queue_sender, messages, bundle).await { eprintln!("发送消息失败: {}", err); + log_operation( + &pool_clone, + "error", + "llm", + &format!("发送消息失败: {}", err), + json!({}), + ) + .await; } }); active_tasks.insert(op_key, task); @@ -204,6 +265,7 @@ pub async fn create_queue( &typing_sessions, &mut active_turns, &mut active_tasks, + &pool, channel, vec![ToolMessage { tool_call_id, @@ -225,6 +287,7 @@ pub async fn create_queue( &typing_sessions, &mut active_turns, &mut active_tasks, + &pool, channel, tools, ) @@ -243,6 +306,7 @@ async fn process_tool_results( typing_sessions: &HashMap, active_turns: &mut HashMap, active_tasks: &mut HashMap>, + pool: &PgPool, channel: ChannelMessage, tools: Vec, ) { @@ -256,6 +320,21 @@ async fn process_tool_results( let mut messages = None; for tool in tools { println!("\n\n调用{}工具的结果:\n{}", tool.name, tool.result); + log_operation( + pool, + "info", + "tool", + &format!( + "工具结果 channel={} account_id={} from_user_id={} tool_name={}", + channel.channel, channel.account_id, channel.from_user_id, tool.name + ), + json!({ + "tool_name": tool.name, + "tool_call_id": tool.tool_call_id, + "result_preview": text_preview(&tool.result, 1000), + }), + ) + .await; match context_manager .record_tool_message( &channel, @@ -268,6 +347,14 @@ async fn process_tool_results( Ok(snapshot) => messages = Some(snapshot.messages), Err(err) => { eprintln!("记录 tool 上下文失败,停止本轮工具后续请求: {}", err); + log_operation( + pool, + "error", + "context", + &format!("记录 tool 上下文失败 channel={} account_id={} from_user_id={} tool_name={}", channel.channel, channel.account_id, channel.from_user_id, tool.name), + json!({"error": err.to_string(), "tool_name": tool.name}), + ) + .await; return; } } @@ -281,9 +368,18 @@ async fn process_tool_results( eprintln!("工具结果写入后发现 turn 已被打断,跳过后续 LLM 请求"); return; } + let pool_clone = pool.clone(); let task = tokio::spawn(async move { if let Err(err) = send_message(queue_sender, messages, channel).await { eprintln!("发送消息失败: {}", err); + log_operation( + &pool_clone, + "error", + "llm", + &format!("发送消息失败: {}", err), + json!({}), + ) + .await; } }); active_tasks.insert(key, task); @@ -411,6 +507,39 @@ fn is_active_turn(active_turns: &HashMap, channel: &ChannelMessa .is_some_and(|active| active == turn_id) } +fn text_preview(value: &str, max_bytes: usize) -> &str { + if value.len() <= max_bytes { + return value; + } + + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +#[cfg(test)] +mod tests { + use super::text_preview; + + #[test] + fn text_preview_uses_utf8_char_boundary() { + let prefix = "a".repeat(498); + let text = format!("{prefix}摘"); + + assert_eq!(text.as_bytes().len(), 501); + assert_eq!(text_preview(&text, 500), prefix); + } + + #[test] + fn text_preview_keeps_complete_short_text() { + let text = "中文回复"; + + assert_eq!(text_preview(text, 500), text); + } +} + // pub async fn send_to_queue( // State(state): State>, // Json(body): Json, diff --git a/ias-service/src/lib.rs b/ias-service/src/lib.rs index 3a16665..6fd7bcb 100644 --- a/ias-service/src/lib.rs +++ b/ias-service/src/lib.rs @@ -6,9 +6,6 @@ mod core; pub mod model; mod repository; -// 由 build.rs 生成,确保 migration 变更触发完整重编译 -include!(concat!(env!("OUT_DIR"), "/migration_stamp.rs")); - use chrono::{Duration, Utc}; use core::control::{ControlResponse, RuntimePaths, request_control, start_control_server}; use core::queue::create_queue; @@ -21,7 +18,7 @@ use ias_http::create_http; use ias_http::web::{internal_url_for_path, public_url_for_path}; use ias_mail::repository::MailRepository; use ias_mail::{MailNotification, MailPollerConfig, start_mail_polling}; -use sqlx::{PgPool, postgres::PgPoolOptions}; +use sqlx::PgPool; use std::env; use std::sync::Arc; @@ -47,16 +44,6 @@ pub struct AppState { pub runtime: Arc, } -pub async fn connect_db() -> anyhow::Result { - let db_url = env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("必须设置DATABASE_URL"))?; - let pool = PgPoolOptions::new() - .max_connections(10) - .connect(&db_url) - .await?; - sqlx::migrate!("./migrations").run(&pool).await?; - Ok(pool) -} - pub fn init_logging() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); @@ -88,7 +75,14 @@ pub async fn run_service(pool: PgPool) -> anyhow::Result<()> { send_version_update_notice(&state.db, manager.as_ref()).await; let mail_tasks = start_mail_tasks(state.db.clone(), manager.clone()).await; let context_manager = ContextManager::new(state.db.clone()); - let queue_task = create_queue(sender.clone(), receiver, manager, context_manager).await?; + let queue_task = create_queue( + sender.clone(), + receiver, + manager, + context_manager, + state.db.clone(), + ) + .await?; runtime.set_queue_alive(true).await; let (shutdown_tx, shutdown_rx) = watch::channel(false); diff --git a/ias-wechat/Cargo.toml b/ias-wechat/Cargo.toml index 6d0bd9a..ca76b10 100644 --- a/ias-wechat/Cargo.toml +++ b/ias-wechat/Cargo.toml @@ -21,6 +21,6 @@ tracing = "0.1" # 结构化日志 tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 日志输出 tracing-appender = "0.2.5" # 文件日志(日滚) # ── 二维码 ── -qrcode = { version = "0.14", default-features = false } +qrcode = { version = "0.14", default-features = false } # ── UUID ── uuid = { version = "1", features = ["v4", "serde"] } # 唯一标识符 # 二维码生成(仅 Unicode 终端渲染) diff --git a/tools/amap_geocode.yaml b/tools/amap_geocode.yaml new file mode 100644 index 0000000..cceaa5b --- /dev/null +++ b/tools/amap_geocode.yaml @@ -0,0 +1,66 @@ +name: amap_geocode +desc: 高德地图地理编码/逆地理编码 API。将结构化地址转换为经纬度坐标(geo),或将经纬度转换为详细地址(regeo)。支持地标性建筑名称解析。 +params: + - name: type + type: string + required: true + description: 操作类型。geo=地址转经纬度(地理编码),regeo=经纬度转地址(逆地理编码) + - name: address + type: string + required: false + description: 结构化地址信息,type=geo 时必填。如"北京市朝阳区阜通东大街6号"。支持地标性建筑名称如"天安门" + - name: location + type: string + required: false + description: 经纬度坐标,type=regeo 时必填。格式"经度,纬度",小数点后不超过 6 位 + - name: city + type: string + required: false + description: 指定查询城市,type=geo 时可选。支持中文/全拼/citycode/adcode。为空则全国搜索 + - name: radius + type: integer + required: false + default: "1000" + description: type=regeo 时搜索附近 POI 的半径,0~3000 米,默认 1000 + - name: extensions + type: string + required: false + default: "base" + description: type=regeo 时返回结果控制。base=基本地址信息,all=含附近 POI、道路、交叉口信息 + - name: poitype + type: string + required: false + description: type=regeo 且 extensions=all 时,限定附近 POI 类型,多个用 | 分隔 +env: + - AMAP_API_KEY +steps: + - url: https://restapi.amap.com/v3/geocode/${param:type} + method: GET + query: + - [key, "${env:AMAP_API_KEY}"] + - [address, "${param:address}"] + - [location, "${param:location}"] + - [city, "${param:city}"] + - [radius, "${param:radius}"] + - [extensions, "${param:extensions}"] + - [poitype, "${param:poitype}"] + timeout: 15 + var: + - name: status + extract: status + - name: info + extract: info + - name: count + extract: count + default: "0" + - name: geocodes + extract: geocodes + default: "" + - name: regeocode + extract: regeocode + default: "" + result: | + status=${step0:status}, info=${step0:info} + count=${step0:count} + geocodes=${step0:geocodes} + regeocode=${step0:regeocode} diff --git a/web/src/App.tsx b/web/src/App.tsx index c635f7a..bab9027 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { MailDetailPage } from "./pages/MailDetailPage"; import { ToolsPage } from "./pages/ToolsPage"; import { ConfigsPage } from "./pages/ConfigsPage"; import { ContextSimulatorPage } from "./pages/ContextSimulatorPage"; +import { LogsPage } from "./pages/LogsPage"; import { HomePage } from "./pages/HomePage"; import { GenericWebPageDetailPage, @@ -49,6 +50,12 @@ function App() { > 配置 + + 日志 + } /> } /> } /> + } /> } /> diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx new file mode 100644 index 0000000..90994ee --- /dev/null +++ b/web/src/pages/LogsPage.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from "react"; +import { useSearchParams, Link } from "react-router-dom"; +import { apiGet } from "../lib/api"; + +interface OperationLog { + id: number; + level: string; + module: string; + message: string; + metadata: Record; + created_at: string; +} + +interface LogListResponse { + success: boolean; + page: number; + per_page: number; + total: number; + logs: OperationLog[]; +} + +const LEVELS = ["", "info", "warn", "error", "debug"] as const; +const LEVEL_LABELS: Record = { + "": "全部级别", + info: "INFO", + warn: "WARN", + error: "ERROR", + debug: "DEBUG", +}; + +const LEVEL_COLORS: Record = { + info: "bg-blue-50 text-blue-700", + warn: "bg-yellow-50 text-yellow-700", + error: "bg-red-50 text-red-700", + debug: "bg-gray-100 text-gray-600", +}; + +export function LogsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const page = Number(searchParams.get("page") || "1"); + const perPage = Number(searchParams.get("per_page") || "20"); + const level = searchParams.get("level") || ""; + const module = searchParams.get("module") || ""; + const q = searchParams.get("q") || ""; + + useEffect(() => { + setLoading(true); + setError(null); + const params: Record = { page, per_page: perPage }; + if (level) params.level = level; + if (module) params.module = module; + if (q) params.q = q; + + apiGet("/v1/logs", params) + .then(setData) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [page, perPage, level, module, q]); + + const updateParams = (updates: Record) => { + const newParams = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (value) { + newParams.set(key, value); + } else { + newParams.delete(key); + } + }); + if ( + updates.level !== undefined || + updates.module !== undefined || + updates.q !== undefined + ) { + newParams.set("page", "1"); + } + setSearchParams(newParams); + }; + + const hasFilter = level || module || q; + const totalPages = data ? Math.ceil(data.total / data.per_page) : 0; + + const formatTime = (t: string) => { + if (!t) return "-"; + const d = new Date(t); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`; + }; + + const truncate = (s: string, max: number) => { + if (!s) return ""; + return s.length > max ? s.slice(0, max - 1) + "…" : s; + }; + + return ( +
+
+

+ 📋 操作日志 +

+

+ {data + ? hasFilter + ? `筛选结果:共 ${data.total} 条,当前 ${(page - 1) * perPage + 1}–${Math.min(page * perPage, data.total)}` + : `共 ${data.total} 条日志` + : "加载中..."} +

+
+ + {/* 筛选栏 */} +
{ + e.preventDefault(); + const form = new FormData(e.currentTarget); + updateParams({ + level: (form.get("level") as string) || "", + module: (form.get("module") as string) || "", + q: (form.get("q") as string) || "", + }); + }} + className="bg-white rounded-xl border border-gray-200 p-4 md:p-5 mb-5" + > +
+
+ +
+
+ + +
+
+ + {hasFilter && ( + + 清除 + + )} +
+
+
+ + {loading && ( +
+
加载中...
+
+ )} + + {error && ( +
+

加载失败:{error}

+
+ )} + + {data && !loading && ( + <> +
+ {data.logs.length === 0 ? ( +
+
📭
+

没有匹配的日志

+
+ ) : ( + data.logs.map((log) => ( +
+
+
+ + {log.level} + + {log.module && ( + + {log.module} + + )} + #{log.id} +
+ + {formatTime(log.created_at)} + +
+

+ {truncate(log.message, 200)} +

+ {log.metadata && Object.keys(log.metadata).length > 0 && ( +
+											{JSON.stringify(log.metadata, null, 2)}
+										
+ )} +
+ )) + )} +
+ + {/* 分页 */} + {totalPages > 1 && ( + + )} + + )} +
+ ); +}