feat: 0.2.27 - 抽离数据库基础设施模块

- 新增 ias-db crate,集中管理 PostgreSQL 连接池和 SQLx 迁移
- 迁移 ias-service/migrations 到 ias-db/migrations
- ias-main 改用 ias_db::connect_db(),ias-service 只接 PgPool
- 新增操作日志 HTTP 查询接口 (GET /v1/logs, GET /v1/logs/{id})
- 新增操作日志 Web 前端页面,支持按级别/模块/关键词筛选
- 消息队列关键节点增加操作日志写入
- ias-mail 新增 mark_remote_message_seen,IMAP 已读同步
- 工作流配置加载改为文件级容错 (load_configs_with_warnings)
- ias-context 工具调用说明更新为 query_api_docs + call_http_api
- 新增高德地图地理编码 HTTP 能力配置

模块版本: 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
This commit is contained in:
2026-07-08 17:44:21 +08:00
parent 7d5140c8d2
commit 7deae286ca
83 changed files with 1440 additions and 107 deletions
+4 -2
View File
@@ -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` 的版本、日期和变更要保持一致。
- 升级日志应使用中文撰写,记录用户可感知变化、数据库迁移、配置项变化和关键验证结果。
+40
View File
@@ -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` 调用接口。
Generated
+15 -7
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
0.2.22
0.2.27
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-ai"
version = "0.1.11"
version = "0.1.14"
edition = "2024"
[dependencies]
+17 -13
View File
@@ -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()
+70 -5
View File
@@ -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<FlowConfig>,
pub warnings: Vec<String>,
}
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<String> {
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<Vec<FlowConfig>> {
Ok(load_configs_with_warnings(path).await?.configs)
}
pub async fn load_configs_with_warnings(path: String) -> anyhow::Result<LoadedFlowConfigs> {
let mut configs: Vec<FlowConfig> = vec![];
let mut warnings: Vec<String> = 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<FlowConfig> {
@@ -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()))
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-context"
version = "0.1.4"
version = "0.1.6"
edition = "2024"
[dependencies]
+12 -1
View File
@@ -37,7 +37,7 @@ pub fn tool_call_instruction_section(content: &str) -> Option<String> {
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![
+15
View File
@@ -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",
] }
-3
View File
@@ -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_DIRlib.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(
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+27
View File
@@ -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<DbPool> {
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<DbPool> {
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(())
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-http"
version = "0.1.13"
version = "0.1.15"
edition = "2024"
[dependencies]
+54 -5
View File
@@ -59,9 +59,9 @@ async fn list_api_docs(Query(query): Query<ApiDocQuery>) -> Json<Value> {
.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::<Vec<_>>();
let total = docs.len();
let matched_groups = docs.iter().map(|doc| doc.group).collect::<BTreeSet<_>>();
@@ -70,10 +70,10 @@ async fn list_api_docs(Query(query): Query<ApiDocQuery>) -> Json<Value> {
.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::<Vec<_>>();
@@ -204,6 +204,21 @@ fn api_groups() -> Vec<ApiGroupDoc> {
"scope",
],
},
ApiGroupDoc {
id: "logs",
title: "操作日志",
description: "用于查询系统操作日志,支持按级别、模块和关键词筛选,帮助排障和审计。",
aspects: &[
"日志列表",
"日志筛选",
"单条日志详情",
"按级别过滤",
"按模块过滤",
],
keywords: &[
"log", "日志", "操作", "审计", "级别", "error", "warn", "info",
],
},
]
}
@@ -562,6 +577,40 @@ fn api_docs() -> Vec<ApiDoc> {
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 {
+2
View File
@@ -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)
+1
View File
@@ -1,5 +1,6 @@
pub mod api_docs;
pub mod app;
pub mod logs;
pub mod updates;
pub mod web;
+410
View File
@@ -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<Value>);
#[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<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct LogListQuery {
pub level: Option<String>,
pub module: Option<String>,
pub q: Option<String>,
pub page: Option<i64>,
pub per_page: Option<i64>,
}
// ── 路由 ────────────────────────────────────────────────
pub fn routes<S>(db: PgPool) -> Router<S>
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<LogsRouteState>,
Query(query): Query<LogListQuery>,
) -> Result<Json<Value>, 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<LogsRouteState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<OperationLog>, 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<Option<OperationLog>> {
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<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn api_not_found(message: impl Into<String>) -> 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}");
}
}
+1 -1
View File
@@ -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<String> {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-mail"
version = "0.1.15"
version = "0.1.16"
edition = "2024"
[dependencies]
+64 -31
View File
@@ -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<Fetch> = session
.uid_store(uid, "+FLAGS.SILENT (\\Seen)")
.await
.with_context(|| format!("设置邮件已读失败 uid={uid}"))?
.try_collect()
.await
.with_context(|| format!("确认邮件已读同步失败 uid={uid}"))?;
let fetches: Vec<Fetch> = 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<Vec<FetchedMail>> {
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<Vec<
Ok(messages)
}
fn normalized_imap_uid(uid: &str) -> 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<String> {
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<async_imap::Session<tokio_native_tls::TlsStream<TcpStream>>> {
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<FetchedMail> {
@@ -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 <alice@example.com>\r\nSubject: Hi\r\n\r\nBody";
+34 -4
View File
@@ -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<Value>);
@@ -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<bool> {
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<Option<MailPollerConfig>> {
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<String>) -> ApiError {
(
StatusCode::NOT_FOUND,
+2 -8
View File
@@ -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"
+2 -2
View File
@@ -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]
+1 -3
View File
@@ -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",
] }
# ── 错误处理 ──
+129
View File
@@ -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<QueueMessage>,
manager: Arc<WeChatMultiAccountManager>,
context_manager: ContextManager,
pool: PgPool,
) -> anyhow::Result<JoinHandle<()>> {
let handle = tokio::spawn(async move {
let mut typing_sessions: HashMap<String, TypingSession> = 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::<Vec<_>>()),
}),
)
.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<String, TypingSession>,
active_turns: &mut HashMap<String, String>,
active_tasks: &mut HashMap<String, JoinHandle<()>>,
pool: &PgPool,
channel: ChannelMessage,
tools: Vec<ToolMessage>,
) {
@@ -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<String, String>, 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<Arc<AppState>>,
// Json(body): Json<ChannelMessageBundle>,
+9 -15
View File
@@ -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<RuntimeState>,
}
pub async fn connect_db() -> anyhow::Result<PgPool> {
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);
+66
View File
@@ -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}
+8
View File
@@ -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() {
>
</Link>
<Link
to="/logs"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
<Link
to="/context"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
@@ -69,6 +76,7 @@ function App() {
<Route path="/web/:pageType/:slug" element={<GenericWebPageDetailPage />} />
<Route path="/tools" element={<ToolsPage />} />
<Route path="/configs" element={<ConfigsPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/context" element={<ContextSimulatorPage />} />
</Routes>
</main>
+265
View File
@@ -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<string, unknown>;
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<string, string> = {
"": "全部级别",
info: "INFO",
warn: "WARN",
error: "ERROR",
debug: "DEBUG",
};
const LEVEL_COLORS: Record<string, string> = {
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<LogListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, string | number> = { page, per_page: perPage };
if (level) params.level = level;
if (module) params.module = module;
if (q) params.q = q;
apiGet<LogListResponse>("/v1/logs", params)
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [page, perPage, level, module, q]);
const updateParams = (updates: Record<string, string>) => {
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 (
<div>
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
📋
</h1>
<p className="mt-1.5 text-sm text-gray-500">
{data
? hasFilter
? `筛选结果:共 ${data.total} 条,当前 ${(page - 1) * perPage + 1}${Math.min(page * perPage, data.total)}`
: `${data.total} 条日志`
: "加载中..."}
</p>
</div>
{/* 筛选栏 */}
<form
onSubmit={(e) => {
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"
>
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1 min-w-0">
<input
name="q"
defaultValue={q}
placeholder="搜索日志消息或元数据…"
className="w-full border border-gray-200 rounded-lg px-3.5 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
</div>
<div className="flex gap-2">
<select
name="level"
defaultValue={level}
className="w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-white focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
>
{LEVELS.map((lv) => (
<option key={lv} value={lv}>
{LEVEL_LABELS[lv]}
</option>
))}
</select>
<input
name="module"
defaultValue={module}
placeholder="模块"
className="w-32 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="inline-flex items-center justify-center px-5 py-2.5 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition shadow-sm"
>
</button>
{hasFilter && (
<Link
to="/logs"
className="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 transition"
>
</Link>
)}
</div>
</div>
</form>
{loading && (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
)}
{error && (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
)}
{data && !loading && (
<>
<div className="flex flex-col gap-3 mb-4">
{data.logs.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<div className="text-4xl mb-3">📭</div>
<p className="text-base"></p>
</div>
) : (
data.logs.map((log) => (
<div
key={log.id}
className="block bg-white rounded-xl border border-gray-200 p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition group"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium uppercase ${LEVEL_COLORS[log.level] || "bg-gray-100 text-gray-600"}`}
>
{log.level}
</span>
{log.module && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-purple-50 text-purple-600">
{log.module}
</span>
)}
<span className="text-xs text-gray-400">#{log.id}</span>
</div>
<span className="shrink-0 text-xs text-gray-400 whitespace-nowrap">
{formatTime(log.created_at)}
</span>
</div>
<p className="text-sm text-gray-700 leading-relaxed break-words">
{truncate(log.message, 200)}
</p>
{log.metadata && Object.keys(log.metadata).length > 0 && (
<pre className="mt-2 text-xs text-gray-400 bg-gray-50 rounded-lg p-2 overflow-x-auto max-h-24">
{JSON.stringify(log.metadata, null, 2)}
</pre>
)}
</div>
))
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3 mt-5">
{page > 1 ? (
<Link
to={`/logs?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page - 1) })}`}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
>
</Link>
) : (
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
</span>
)}
<span className="text-sm text-gray-400">
{page} / {totalPages}
</span>
{page < totalPages ? (
<Link
to={`/logs?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page + 1) })}`}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
>
</Link>
) : (
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
</span>
)}
</nav>
)}
</>
)}
</div>
);
}