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:
@@ -11,8 +11,10 @@ primary use Chinese!!!
|
|||||||
|
|
||||||
- 每个模块独立维护版本号,记录在各自 `Cargo.toml` 的 `package.version`。
|
- 每个模块独立维护版本号,记录在各自 `Cargo.toml` 的 `package.version`。
|
||||||
- `ias-main` 的版本号代表整体项目版本,记录在根目录 `VERSION`。
|
- `ias-main` 的版本号代表整体项目版本,记录在根目录 `VERSION`。
|
||||||
- 每次完成代码或行为变更,必须更新受影响模块的版本号;如果 `ias-main` 也受影响,同步更新 `VERSION`。
|
- 版本号、`VERSION`、`CHANGELOG.md` 和 `ias_upgrade_logs` 记录在准备提交代码或用户明确要求收尾时统一处理;纯工作中间态不强制立即更新。
|
||||||
- 同步更新 `CHANGELOG.md`,并通过 `service/migrations` 将升级记录写入 `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 字段记录各模块版本。
|
- `ias_upgrade_logs` 的 `version` 对应 `ias-main` 整体版本,`modules` JSONB 字段记录各模块版本。
|
||||||
- `CHANGELOG.md` 与 `ias_upgrade_logs` 的版本、日期和变更要保持一致。
|
- `CHANGELOG.md` 与 `ias_upgrade_logs` 的版本、日期和变更要保持一致。
|
||||||
- 升级日志应使用中文撰写,记录用户可感知变化、数据库迁移、配置项变化和关键验证结果。
|
- 升级日志应使用中文撰写,记录用户可感知变化、数据库迁移、配置项变化和关键验证结果。
|
||||||
|
|||||||
@@ -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
|
## 0.2.22 - 2026-07-08
|
||||||
|
|
||||||
- AI API 能力去重:移除与 HTTP endpoint 一一重复的专用能力(如 web 页面、邮件查询、长期记忆和工具调用说明写入包装),统一改为 `query_api_docs` 发现接口、`call_http_api` 按 `endpoint_id` 调用接口。
|
- AI API 能力去重:移除与 HTTP endpoint 一一重复的专用能力(如 web 页面、邮件查询、长期记忆和工具调用说明写入包装),统一改为 `query_api_docs` 发现接口、`call_http_api` 按 `endpoint_id` 调用接口。
|
||||||
|
|||||||
Generated
+15
-7
@@ -1042,7 +1042,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ias-ai"
|
name = "ias-ai"
|
||||||
version = "0.1.11"
|
version = "0.1.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1072,7 +1072,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ias-context"
|
name = "ias-context"
|
||||||
version = "0.1.4"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1084,9 +1084,17 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ias-db"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"sqlx",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ias-http"
|
name = "ias-http"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1101,7 +1109,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ias-mail"
|
name = "ias-mail"
|
||||||
version = "0.1.15"
|
version = "0.1.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-imap",
|
"async-imap",
|
||||||
@@ -1120,14 +1128,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ias-main"
|
name = "ias-main"
|
||||||
version = "0.2.22"
|
version = "0.2.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"ias-db",
|
||||||
"ias-mail",
|
"ias-mail",
|
||||||
"ias-service",
|
"ias-service",
|
||||||
"sqlx",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -1135,7 +1143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ias-service"
|
name = "ias-service"
|
||||||
version = "0.1.19"
|
version = "0.1.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[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"
|
resolver = "2"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ias-ai"
|
name = "ias-ai"
|
||||||
version = "0.1.11"
|
version = "0.1.14"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+17
-13
@@ -3,7 +3,7 @@ use crate::{
|
|||||||
tools::http_api::call_http_api,
|
tools::http_api::call_http_api,
|
||||||
tools::tool_instructions::query_tool_call_instruction,
|
tools::tool_instructions::query_tool_call_instruction,
|
||||||
workflow::{
|
workflow::{
|
||||||
core::{call_flow, load_configs},
|
core::{call_flow, load_configs_with_warnings},
|
||||||
types::{FlowConfig, FlowParam},
|
types::{FlowConfig, FlowParam},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -135,7 +135,7 @@ async fn query_capabilities(channel: &ChannelMessage, tool_path: &str) -> String
|
|||||||
let mut result = serde_json::json!({
|
let mut result = serde_json::json!({
|
||||||
"api_capabilities": capability_briefs(&inventory.api_tools),
|
"api_capabilities": capability_briefs(&inventory.api_tools),
|
||||||
"http_capabilities": capability_briefs(&inventory.http_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(),
|
"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、参数和请求体。",
|
"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 mut warnings = Vec::new();
|
||||||
let http_tools = match load_configs(tool_path.to_string()).await {
|
let http_tools = match load_configs_with_warnings(tool_path.to_string()).await {
|
||||||
Ok(configs) => configs
|
Ok(loaded) => {
|
||||||
.into_iter()
|
warnings.extend(loaded.warnings);
|
||||||
.filter_map(|config| {
|
loaded
|
||||||
if seen.insert(config.name.clone()) {
|
.configs
|
||||||
Some(capability_from_flow_config(config, None))
|
.into_iter()
|
||||||
} else {
|
.filter_map(|config| {
|
||||||
None
|
if seen.insert(config.name.clone()) {
|
||||||
}
|
Some(capability_from_flow_config(config, None))
|
||||||
})
|
} else {
|
||||||
.collect(),
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
warnings.push(format!("加载 HTTP 能力失败: {error}"));
|
warnings.push(format!("加载 HTTP 能力失败: {error}"));
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ use tokio::fs;
|
|||||||
use crate::workflow::flow::start_flow;
|
use crate::workflow::flow::start_flow;
|
||||||
use crate::workflow::types::FlowConfig;
|
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 {
|
pub async fn call_flow(path: String, args: String) -> String {
|
||||||
let all_flow = match load_configs(path).await {
|
let all_flow = match load_configs(path).await {
|
||||||
Ok(flows) => flows,
|
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> {
|
pub async fn load_tools_define(path: String) -> anyhow::Result<String> {
|
||||||
let configs = load_configs(path).await?;
|
let loaded = load_configs_with_warnings(path).await?;
|
||||||
Ok(serde_json::to_string(&configs).unwrap_or_default())
|
Ok(serde_json::to_string(&loaded.configs).unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_configs(path: String) -> anyhow::Result<Vec<FlowConfig>> {
|
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 configs: Vec<FlowConfig> = vec![];
|
||||||
|
let mut warnings: Vec<String> = vec![];
|
||||||
let config_files = scan_config_dir(path).await?;
|
let config_files = scan_config_dir(path).await?;
|
||||||
if !config_files.is_empty() {
|
if !config_files.is_empty() {
|
||||||
for file in config_files {
|
for file in config_files {
|
||||||
let config = load_yaml(file).await?;
|
match load_yaml(file.clone()).await {
|
||||||
configs.push(config);
|
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> {
|
pub async fn load_yaml(path: String) -> anyhow::Result<FlowConfig> {
|
||||||
@@ -82,3 +95,55 @@ async fn list_file() {
|
|||||||
|
|
||||||
println!();
|
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,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ias-context"
|
name = "ias-context"
|
||||||
version = "0.1.4"
|
version = "0.1.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pub fn tool_call_instruction_section(content: &str) -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(format!(
|
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)
|
trim_to_chars(content, 4_000)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -900,6 +900,7 @@ fn read_i64_env(name: &str, default: i64) -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_fixed_system_prompt, detect_assistant_name_preference, sanitize_tool_call_messages,
|
build_fixed_system_prompt, detect_assistant_name_preference, sanitize_tool_call_messages,
|
||||||
|
tool_call_instruction_section,
|
||||||
};
|
};
|
||||||
use ias_common::model::ai::{ChatCompletionRequestMessage, ChatCompletionToolCall};
|
use ias_common::model::ai::{ChatCompletionRequestMessage, ChatCompletionToolCall};
|
||||||
|
|
||||||
@@ -944,6 +945,16 @@ mod tests {
|
|||||||
assert!(prompt.contains("禁止臆造工具名或直接调用其它能力"));
|
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]
|
#[test]
|
||||||
fn keeps_complete_multi_tool_call_sequence() {
|
fn keeps_complete_multi_tool_call_sequence() {
|
||||||
let messages = sanitize_tool_call_messages(vec![
|
let messages = sanitize_tool_call_messages(vec![
|
||||||
|
|||||||
@@ -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",
|
||||||
|
] }
|
||||||
@@ -11,14 +11,11 @@ fn main() {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().is_some_and(|extension| extension == "sql") {
|
if path.extension().is_some_and(|extension| extension == "sql") {
|
||||||
println!("cargo:rerun-if-changed={}", path.display());
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
// 将文件大小加入计数,确保新增/删除/修改文件都改变输出
|
|
||||||
count = count.wrapping_add(fs::metadata(&path).map(|m| m.len()).unwrap_or(0));
|
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 out_dir = std::env::var("OUT_DIR").unwrap();
|
||||||
let dest = Path::new(&out_dir).join("migration_stamp.rs");
|
let dest = Path::new(&out_dir).join("migration_stamp.rs");
|
||||||
fs::write(
|
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;
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ias-http"
|
name = "ias-http"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ async fn list_api_docs(Query(query): Query<ApiDocQuery>) -> Json<Value> {
|
|||||||
.filter(|doc| {
|
.filter(|doc| {
|
||||||
group
|
group
|
||||||
.as_deref()
|
.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<_>>();
|
.collect::<Vec<_>>();
|
||||||
let total = docs.len();
|
let total = docs.len();
|
||||||
let matched_groups = docs.iter().map(|doc| doc.group).collect::<BTreeSet<_>>();
|
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| {
|
.filter(|api_group| {
|
||||||
group
|
group
|
||||||
.as_deref()
|
.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| {
|
.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)
|
|| matched_groups.contains(api_group.id)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -204,6 +204,21 @@ fn api_groups() -> Vec<ApiGroupDoc> {
|
|||||||
"scope",
|
"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":[...]}"#,
|
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 时只返回默认调试数据。"],
|
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 {
|
fn doc_or_group_matches(doc: &ApiDoc, query: &str) -> bool {
|
||||||
doc_matches(doc, query)
|
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 {
|
fn group_matches(group: &ApiGroupDoc, query: &str) -> bool {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use tower_http::cors::{Any, CorsLayer};
|
|||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
use crate::api_docs::routes as api_doc_routes;
|
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::updates::routes as update_routes;
|
||||||
use crate::web::routes as web_routes;
|
use crate::web::routes as web_routes;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ pub async fn create_http(
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/v1/health", get(health))
|
.route("/v1/health", get(health))
|
||||||
.merge(api_doc_routes::<()>())
|
.merge(api_doc_routes::<()>())
|
||||||
|
.merge(log_routes::<()>(db.clone()))
|
||||||
.merge(update_routes::<()>(db.clone()))
|
.merge(update_routes::<()>(db.clone()))
|
||||||
.merge(web_routes::routes::<()>(db))
|
.merge(web_routes::routes::<()>(db))
|
||||||
.merge(extra_routes)
|
.merge(extra_routes)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod api_docs;
|
pub mod api_docs;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod logs;
|
||||||
pub mod updates;
|
pub mod updates;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ fn configured_internal_url() -> String {
|
|||||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
|
.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> {
|
fn configured_base_url_from_env() -> Option<String> {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ias-mail"
|
name = "ias-mail"
|
||||||
version = "0.1.15"
|
version = "0.1.16"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+64
-31
@@ -171,28 +171,37 @@ pub async fn test_connection(config: MailPollerConfig) -> anyhow::Result<()> {
|
|||||||
test_connection_async(&config).await
|
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>> {
|
async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<FetchedMail>> {
|
||||||
let addr = format!("{}:{}", config.host, config.port);
|
let mut session = connect_imap_session(config).await?;
|
||||||
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 mailbox = session
|
let mailbox = session
|
||||||
.select(&config.mailbox)
|
.select(&config.mailbox)
|
||||||
.await
|
.await
|
||||||
@@ -233,6 +242,14 @@ async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
|
|||||||
Ok(messages)
|
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> {
|
fn recent_sequence_set(exists: u32, limit: u32) -> Option<String> {
|
||||||
if exists == 0 || limit == 0 {
|
if exists == 0 || limit == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -251,6 +268,18 @@ fn is_seen(fetch: &Fetch) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> {
|
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 addr = format!("{}:{}", config.host, config.port);
|
||||||
let tcp_stream = TcpStream::connect(&addr)
|
let tcp_stream = TcpStream::connect(&addr)
|
||||||
.await
|
.await
|
||||||
@@ -267,18 +296,12 @@ async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()>
|
|||||||
.with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?;
|
.with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?;
|
||||||
|
|
||||||
let client = async_imap::Client::new(tls_stream);
|
let client = async_imap::Client::new(tls_stream);
|
||||||
let mut session = client
|
let session = client
|
||||||
.login(&config.username, &config.password)
|
.login(&config.username, &config.password)
|
||||||
.await
|
.await
|
||||||
.map_err(|(error, _)| error)
|
.map_err(|(error, _)| error)
|
||||||
.context("IMAP 登录失败")?;
|
.context("IMAP 登录失败")?;
|
||||||
|
Ok(session)
|
||||||
session
|
|
||||||
.select(&config.mailbox)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
|
|
||||||
let _ = session.logout().await;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedMail> {
|
fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedMail> {
|
||||||
@@ -506,8 +529,9 @@ fn split_headers(raw: &str) -> &str {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, parse_message,
|
AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, normalized_imap_uid,
|
||||||
preview_text, recent_sequence_set, should_skip_fetched_message, summarize_mail,
|
parse_message, preview_text, recent_sequence_set, should_skip_fetched_message,
|
||||||
|
summarize_mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -530,6 +554,15 @@ mod tests {
|
|||||||
assert!(!should_skip_fetched_message(false, true));
|
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]
|
#[test]
|
||||||
fn reads_folded_header() {
|
fn reads_folded_header() {
|
||||||
let raw = b"From: Alice\r\n <alice@example.com>\r\nSubject: Hi\r\n\r\nBody";
|
let raw = b"From: Alice\r\n <alice@example.com>\r\nSubject: Hi\r\n\r\nBody";
|
||||||
|
|||||||
+34
-4
@@ -5,7 +5,9 @@ use axum::{Extension, Json, Router};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::PgPool;
|
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;
|
use crate::repository::MailRepository;
|
||||||
|
|
||||||
type ApiError = (StatusCode, Json<Value>);
|
type ApiError = (StatusCode, Json<Value>);
|
||||||
@@ -60,12 +62,11 @@ async fn get_message(
|
|||||||
.map_err(api_internal_error)?
|
.map_err(api_internal_error)?
|
||||||
.ok_or_else(|| api_not_found("邮件记录不存在"))?;
|
.ok_or_else(|| api_not_found("邮件记录不存在"))?;
|
||||||
|
|
||||||
// 标记已读
|
|
||||||
if !message.seen {
|
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(true) => message.seen = true,
|
||||||
Ok(false) => {}
|
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 {
|
fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||||
(
|
(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|||||||
+2
-8
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ias-main"
|
name = "ias-main"
|
||||||
version = "0.2.22"
|
version = "0.2.27"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -8,17 +8,11 @@ name = "ias"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ias-db = { path = "../ias-db" }
|
||||||
ias-service = { path = "../ias-service" }
|
ias-service = { path = "../ias-service" }
|
||||||
ias-mail = { path = "../ias-mail" }
|
ias-mail = { path = "../ias-mail" }
|
||||||
clap = { version = "4.6.1", features = ["derive"] }
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
sqlx = { version = "0.9.0", features = [
|
|
||||||
"runtime-tokio",
|
|
||||||
"tls-rustls",
|
|
||||||
"postgres",
|
|
||||||
"migrate",
|
|
||||||
"chrono",
|
|
||||||
] }
|
|
||||||
tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ mod cli;
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::{Channels, Cli, Commands, MailAction, ServiceAction, WechatAction};
|
use cli::{Channels, Cli, Commands, MailAction, ServiceAction, WechatAction};
|
||||||
|
use ias_db::connect_db;
|
||||||
use ias_service::supervisor::{
|
use ias_service::supervisor::{
|
||||||
print_logs, restart_service, start_service, status_service, stop_service,
|
print_logs, restart_service, start_service, status_service, stop_service,
|
||||||
};
|
};
|
||||||
use ias_service::{
|
use ias_service::{
|
||||||
connect_db, delete_account, init_logging, list_accounts, login_user, rename_account,
|
delete_account, init_logging, list_accounts, login_user, rename_account, run_service,
|
||||||
run_service,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ias-service"
|
name = "ias-service"
|
||||||
version = "0.1.19"
|
version = "0.1.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -46,8 +46,6 @@ sqlx = { version = "0.9.0", features = [
|
|||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
"tls-rustls",
|
"tls-rustls",
|
||||||
"postgres",
|
"postgres",
|
||||||
"macros",
|
|
||||||
"migrate",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
# ── 错误处理 ──
|
# ── 错误处理 ──
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ use ias_ai::core::send_message;
|
|||||||
use ias_common::model::ai::ChatCompletionRequestMessage;
|
use ias_common::model::ai::ChatCompletionRequestMessage;
|
||||||
use ias_common::queue::{ChannelMessage, QueueMessage, ToolMessage};
|
use ias_common::queue::{ChannelMessage, QueueMessage, ToolMessage};
|
||||||
use ias_context::{ContextManager, NewSessionReason};
|
use ias_context::{ContextManager, NewSessionReason};
|
||||||
|
use ias_http::logs::log_operation;
|
||||||
use ias_wechat::manager::WeChatMultiAccountManager;
|
use ias_wechat::manager::WeChatMultiAccountManager;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -20,6 +23,7 @@ pub async fn create_queue(
|
|||||||
mut receiver: Receiver<QueueMessage>,
|
mut receiver: Receiver<QueueMessage>,
|
||||||
manager: Arc<WeChatMultiAccountManager>,
|
manager: Arc<WeChatMultiAccountManager>,
|
||||||
context_manager: ContextManager,
|
context_manager: ContextManager,
|
||||||
|
pool: PgPool,
|
||||||
) -> anyhow::Result<JoinHandle<()>> {
|
) -> anyhow::Result<JoinHandle<()>> {
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut typing_sessions: HashMap<String, TypingSession> = HashMap::new();
|
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)
|
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 {
|
if has_tool_calls {
|
||||||
refresh_typing(manager.clone(), &typing_sessions, &channel).await;
|
refresh_typing(manager.clone(), &typing_sessions, &channel).await;
|
||||||
} else if !content.trim().is_empty() || !reasoning.trim().is_empty() {
|
} else if !content.trim().is_empty() || !reasoning.trim().is_empty() {
|
||||||
@@ -106,6 +124,14 @@ pub async fn create_queue(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("记录 assistant 上下文失败: {}", err);
|
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 {
|
if !has_tool_calls {
|
||||||
let key = operation_key(&channel);
|
let key = operation_key(&channel);
|
||||||
@@ -140,6 +166,21 @@ pub async fn create_queue(
|
|||||||
bundle.from_user_id.clone(),
|
bundle.from_user_id.clone(),
|
||||||
bundle.content.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);
|
let op_key = operation_key(&bundle);
|
||||||
if bundle.content.trim().eq_ignore_ascii_case("/new") {
|
if bundle.content.trim().eq_ignore_ascii_case("/new") {
|
||||||
if let Some(task) = active_tasks.remove(&op_key) {
|
if let Some(task) = active_tasks.remove(&op_key) {
|
||||||
@@ -179,15 +220,35 @@ pub async fn create_queue(
|
|||||||
Ok(snapshot) => snapshot.messages,
|
Ok(snapshot) => snapshot.messages,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("记录 user 上下文失败,退回单轮消息: {}", 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(
|
vec![ChatCompletionRequestMessage::new(
|
||||||
"user".to_string(),
|
"user".to_string(),
|
||||||
bundle.content.clone(),
|
bundle.content.clone(),
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let pool_clone = pool.clone();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
if let Err(err) = send_message(queue_sender, messages, bundle).await {
|
if let Err(err) = send_message(queue_sender, messages, bundle).await {
|
||||||
eprintln!("发送消息失败: {}", err);
|
eprintln!("发送消息失败: {}", err);
|
||||||
|
log_operation(
|
||||||
|
&pool_clone,
|
||||||
|
"error",
|
||||||
|
"llm",
|
||||||
|
&format!("发送消息失败: {}", err),
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
active_tasks.insert(op_key, task);
|
active_tasks.insert(op_key, task);
|
||||||
@@ -204,6 +265,7 @@ pub async fn create_queue(
|
|||||||
&typing_sessions,
|
&typing_sessions,
|
||||||
&mut active_turns,
|
&mut active_turns,
|
||||||
&mut active_tasks,
|
&mut active_tasks,
|
||||||
|
&pool,
|
||||||
channel,
|
channel,
|
||||||
vec![ToolMessage {
|
vec![ToolMessage {
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
@@ -225,6 +287,7 @@ pub async fn create_queue(
|
|||||||
&typing_sessions,
|
&typing_sessions,
|
||||||
&mut active_turns,
|
&mut active_turns,
|
||||||
&mut active_tasks,
|
&mut active_tasks,
|
||||||
|
&pool,
|
||||||
channel,
|
channel,
|
||||||
tools,
|
tools,
|
||||||
)
|
)
|
||||||
@@ -243,6 +306,7 @@ async fn process_tool_results(
|
|||||||
typing_sessions: &HashMap<String, TypingSession>,
|
typing_sessions: &HashMap<String, TypingSession>,
|
||||||
active_turns: &mut HashMap<String, String>,
|
active_turns: &mut HashMap<String, String>,
|
||||||
active_tasks: &mut HashMap<String, JoinHandle<()>>,
|
active_tasks: &mut HashMap<String, JoinHandle<()>>,
|
||||||
|
pool: &PgPool,
|
||||||
channel: ChannelMessage,
|
channel: ChannelMessage,
|
||||||
tools: Vec<ToolMessage>,
|
tools: Vec<ToolMessage>,
|
||||||
) {
|
) {
|
||||||
@@ -256,6 +320,21 @@ async fn process_tool_results(
|
|||||||
let mut messages = None;
|
let mut messages = None;
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
println!("\n\n调用{}工具的结果:\n{}", tool.name, tool.result);
|
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
|
match context_manager
|
||||||
.record_tool_message(
|
.record_tool_message(
|
||||||
&channel,
|
&channel,
|
||||||
@@ -268,6 +347,14 @@ async fn process_tool_results(
|
|||||||
Ok(snapshot) => messages = Some(snapshot.messages),
|
Ok(snapshot) => messages = Some(snapshot.messages),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("记录 tool 上下文失败,停止本轮工具后续请求: {}", 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,9 +368,18 @@ async fn process_tool_results(
|
|||||||
eprintln!("工具结果写入后发现 turn 已被打断,跳过后续 LLM 请求");
|
eprintln!("工具结果写入后发现 turn 已被打断,跳过后续 LLM 请求");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let pool_clone = pool.clone();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
if let Err(err) = send_message(queue_sender, messages, channel).await {
|
if let Err(err) = send_message(queue_sender, messages, channel).await {
|
||||||
eprintln!("发送消息失败: {}", err);
|
eprintln!("发送消息失败: {}", err);
|
||||||
|
log_operation(
|
||||||
|
&pool_clone,
|
||||||
|
"error",
|
||||||
|
"llm",
|
||||||
|
&format!("发送消息失败: {}", err),
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
active_tasks.insert(key, task);
|
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)
|
.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(
|
// pub async fn send_to_queue(
|
||||||
// State(state): State<Arc<AppState>>,
|
// State(state): State<Arc<AppState>>,
|
||||||
// Json(body): Json<ChannelMessageBundle>,
|
// Json(body): Json<ChannelMessageBundle>,
|
||||||
|
|||||||
+9
-15
@@ -6,9 +6,6 @@ mod core;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
mod repository;
|
mod repository;
|
||||||
|
|
||||||
// 由 build.rs 生成,确保 migration 变更触发完整重编译
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/migration_stamp.rs"));
|
|
||||||
|
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use core::control::{ControlResponse, RuntimePaths, request_control, start_control_server};
|
use core::control::{ControlResponse, RuntimePaths, request_control, start_control_server};
|
||||||
use core::queue::create_queue;
|
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_http::web::{internal_url_for_path, public_url_for_path};
|
||||||
use ias_mail::repository::MailRepository;
|
use ias_mail::repository::MailRepository;
|
||||||
use ias_mail::{MailNotification, MailPollerConfig, start_mail_polling};
|
use ias_mail::{MailNotification, MailPollerConfig, start_mail_polling};
|
||||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
use sqlx::PgPool;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -47,16 +44,6 @@ pub struct AppState {
|
|||||||
pub runtime: Arc<RuntimeState>,
|
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() {
|
pub fn init_logging() {
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
.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;
|
send_version_update_notice(&state.db, manager.as_ref()).await;
|
||||||
let mail_tasks = start_mail_tasks(state.db.clone(), manager.clone()).await;
|
let mail_tasks = start_mail_tasks(state.db.clone(), manager.clone()).await;
|
||||||
let context_manager = ContextManager::new(state.db.clone());
|
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;
|
runtime.set_queue_alive(true).await;
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ tracing = "0.1" # 结构化日志
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 日志输出
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 日志输出
|
||||||
tracing-appender = "0.2.5" # 文件日志(日滚)
|
tracing-appender = "0.2.5" # 文件日志(日滚)
|
||||||
# ── 二维码 ──
|
# ── 二维码 ──
|
||||||
qrcode = { version = "0.14", default-features = false }
|
qrcode = { version = "0.14", default-features = false }
|
||||||
# ── UUID ──
|
# ── UUID ──
|
||||||
uuid = { version = "1", features = ["v4", "serde"] } # 唯一标识符 # 二维码生成(仅 Unicode 终端渲染)
|
uuid = { version = "1", features = ["v4", "serde"] } # 唯一标识符 # 二维码生成(仅 Unicode 终端渲染)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -5,6 +5,7 @@ import { MailDetailPage } from "./pages/MailDetailPage";
|
|||||||
import { ToolsPage } from "./pages/ToolsPage";
|
import { ToolsPage } from "./pages/ToolsPage";
|
||||||
import { ConfigsPage } from "./pages/ConfigsPage";
|
import { ConfigsPage } from "./pages/ConfigsPage";
|
||||||
import { ContextSimulatorPage } from "./pages/ContextSimulatorPage";
|
import { ContextSimulatorPage } from "./pages/ContextSimulatorPage";
|
||||||
|
import { LogsPage } from "./pages/LogsPage";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import {
|
import {
|
||||||
GenericWebPageDetailPage,
|
GenericWebPageDetailPage,
|
||||||
@@ -49,6 +50,12 @@ function App() {
|
|||||||
>
|
>
|
||||||
配置
|
配置
|
||||||
</Link>
|
</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
|
<Link
|
||||||
to="/context"
|
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"
|
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="/web/:pageType/:slug" element={<GenericWebPageDetailPage />} />
|
||||||
<Route path="/tools" element={<ToolsPage />} />
|
<Route path="/tools" element={<ToolsPage />} />
|
||||||
<Route path="/configs" element={<ConfigsPage />} />
|
<Route path="/configs" element={<ConfigsPage />} />
|
||||||
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
<Route path="/context" element={<ContextSimulatorPage />} />
|
<Route path="/context" element={<ContextSimulatorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user