diff --git a/CHANGELOG.md b/CHANGELOG.md index 3025ffa..1a28b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # 更新日志 +## 0.2.15 - 2026-07-07 + +- 新增 Markdown 预览页面能力:`page_type=markdown` 的动态页面公开路径为 `/md/{slug}`,React 前端按 Markdown/GFM 渲染标题、列表、表格、引用、链接和代码块。 +- 新增内置 AI 工具 `create_markdown_preview`,模型可把长篇 Markdown、报告、代码说明或结构化答复创建成预览页,并只在聊天中发送简短摘要和链接,避免平台消息长度限制。 +- `query_web_pages` 支持查询 `markdown` 页面类型,并会返回 `/md/{slug}` 链接;普通动态页面和邮件页面路径保持不变。 +- API 文档补充 Markdown 动态页面说明;HTTP 模块仍只负责 JSON API 与静态资源托管,Markdown 渲染由 React 前端负责。 +- 无数据库结构变化;新增 `0.2.15` 升级日志记录,`ias-ai` 升级至 `0.1.5`,`ias-http` 升级至 `0.1.9`,`ias-service` 升级至 `0.1.12`。 + +## 0.2.14 - 2026-07-07 + +- 新增 HTTP API 文档接口 `/v1/api-docs` 与 `/v1/api-docs/{id}`,支持按关键词、分组和接口 id 查询当前服务的结构化 API 文档。 +- 新增内置 AI 工具 `query_api_docs`,模型可查询 API 路径、参数、请求体、响应示例和备注,并据此用中文回复用户。 +- 新增内置 AI 工具 `query_web_pages`,模型可按页面类型和关键词查询已创建的动态页面,并把相关 `/web/...` 或 `/mail/...` 链接发给用户。 +- 动态页面列表 API `GET /v1/web/pages` 新增 `q` 关键词搜索,可匹配标题、摘要、正文、来源和 metadata,便于查找已创建页面。 +- API 文档覆盖健康检查、升级日志、动态页面、邮件、控制台工具、用户配置、长期记忆和上下文模拟器接口;HTTP 模块继续只负责 JSON API 与静态资源托管,页面展示仍由 React 前端负责。 +- 无数据库结构变化;新增 `0.2.14` 升级日志记录,`ias-ai` 升级至 `0.1.4`,`ias-http` 升级至 `0.1.8`,`ias-service` 升级至 `0.1.11`。 + +## 0.2.13 - 2026-07-07 + +- 修复模型返回多个 `tool_calls` 时只执行第一个工具导致后续请求缺少 tool 消息的问题;现在会为每个 `tool_call_id` 生成对应工具结果。 +- 服务队列新增批量工具结果处理,所有 tool 消息都写入上下文后才触发下一次 LLM 请求,避免中间态消息序列发送给模型。 +- 上下文快照会过滤历史中不完整的 assistant/tool 调用片段,防止已存在的坏数据继续触发 `insufficient tool messages following tool_calls message`。 +- 新增单元测试覆盖完整多工具调用保留、缺失工具结果过滤,以及 `/v1` API catch-all 路由构建。 +- 无数据库结构变化;`ias-common` 升级至 `0.1.1`,`ias-ai` 升级至 `0.1.3`,`ias-context` 升级至 `0.1.2`,`ias-service` 升级至 `0.1.10`。 + +## 0.2.12 - 2026-07-07 + +- 修复 React SPA 静态资源托管的路径安全问题,改用 `tower-http` 静态文件服务,避免手写路径拼接读取 `web/dist` 之外的文件。 +- React 新增动态内容页路由 `/web/{page_type}/{slug}` 与 `/mail/{slug}`,通过 `/v1/web/...` API 拉取数据并渲染,保持 `ias-http` 只负责 API 与静态资源托管的边界。 +- 邮件详情页恢复 HTML 邮件的沙箱 iframe 渲染,纯文本邮件继续按文本展示。 +- 修复上下文组装模拟器提交表单后首次查询仍使用旧 URL 参数的问题。 +- 邮件详情 API 在自动标记已读成功后,会在响应中同步返回 `seen=true`。 +- 无数据库结构变化;`ias-http` 升级至 `0.1.7`,`ias-mail` 升级至 `0.1.15`,`ias-service` 升级至 `0.1.9`。 + +## 0.2.11 - 2026-07-07 + +- 新增上下文组装模拟器页面(`/context`),可查询指定 scope 下的上下文数据,用于分析排错。 +- 页面展示:活跃会话及其消息列表、组装后的系统上下文预览、历史会话表、长期记忆、会话摘要(上次会话 / 近一周)和可用工具列表。 +- React 导航栏新增「上下文」入口;`ias-service` 新增 `context_simulator` 和 `context_simulator_data` 模块。 +- 无数据库结构变化;`ias-service` 升级至 `0.1.8`,`ias-http` 升级至 `0.1.6`。 + +## 0.2.10 - 2026-07-07 + +- 修复邮件详情页自动标记已读后,下一轮 IMAP 轮询可能把本地已读状态回滚为未读的问题;本地已读状态现在不会被远端未读标记降级。 +- 修复从筛选后的邮件列表进入详情页时丢失搜索、已读筛选和分页参数的问题;详情页返回列表、上一封和下一封会保留当前查询上下文。 +- 说明:SMTP 只负责发信,不能设置收件箱邮件已读状态;已读同步需要 IMAP `\Seen` 标记或服务端本地状态维护。 +- 无数据库结构变化;新增 `0.2.10` 升级日志记录,`ias-mail` 升级至 `0.1.14`。 + +## 0.2.9 - 2026-07-07 + +- HTTP 页面职责迁移到 React SPA:`ias-http` 保留 `/v1/updates`、`/v1/web/...` 等 JSON API,页面渲染由 `web/` 前端负责。 +- 新增 React 前端工程,提供更新日志、邮件、工具、配置等页面入口。 +- `ias-http` 删除旧的服务端 HTML shell 与字符串拼接页面渲染代码,新增 React 静态资源托管入口。 +- 无数据库结构变化;新增 `0.2.9` 升级日志记录。 + ## 0.2.8 - 2026-07-07 - 新增邮件已读/未读状态持久化,保存 IMAP `\Seen` 标记到 `ias_mail_messages.seen`。 diff --git a/Cargo.lock b/Cargo.lock index f58370f..bc81797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,6 +929,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1036,7 +1042,7 @@ dependencies = [ [[package]] name = "ias-ai" -version = "0.1.2" +version = "0.1.5" dependencies = [ "anyhow", "chrono", @@ -1054,7 +1060,7 @@ dependencies = [ [[package]] name = "ias-common" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "reqwest", @@ -1066,7 +1072,7 @@ dependencies = [ [[package]] name = "ias-context" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "axum", @@ -1080,7 +1086,7 @@ dependencies = [ [[package]] name = "ias-http" -version = "0.1.4" +version = "0.1.9" dependencies = [ "anyhow", "axum", @@ -1095,7 +1101,7 @@ dependencies = [ [[package]] name = "ias-mail" -version = "0.1.13" +version = "0.1.15" dependencies = [ "anyhow", "async-imap", @@ -1114,7 +1120,7 @@ dependencies = [ [[package]] name = "ias-main" -version = "0.2.8" +version = "0.2.15" dependencies = [ "anyhow", "clap", @@ -1129,7 +1135,7 @@ dependencies = [ [[package]] name = "ias-service" -version = "0.1.7" +version = "0.1.12" dependencies = [ "anyhow", "axum", @@ -1425,6 +1431,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2639,10 +2655,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -2748,6 +2773,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/VERSION b/VERSION index a45be46..b005e30 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.8 +0.2.15 diff --git a/ias-ai/Cargo.toml b/ias-ai/Cargo.toml index 6468f51..897ce7a 100644 --- a/ias-ai/Cargo.toml +++ b/ias-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-ai" -version = "0.1.2" +version = "0.1.5" edition = "2024" [dependencies] diff --git a/ias-ai/src/toolkit.rs b/ias-ai/src/toolkit.rs index eb3005a..600fa6f 100644 --- a/ias-ai/src/toolkit.rs +++ b/ias-ai/src/toolkit.rs @@ -1,13 +1,14 @@ use crate::{ + tools::api_docs::query_api_docs, tools::date_time::get_system_date_time, tools::memory::{delete_memory, query_memory, remember_memory, update_memory}, - tools::web::create_web_page, + tools::web::{create_markdown_preview, create_web_page, query_web_pages}, workflow::core::{call_flow, load_tools_define}, }; use ias_common::{ model::ai::{ChatCompletionFunction, ChatCompletionToolCall}, - queue::{ChannelMessage, QueueMessage}, + queue::{ChannelMessage, QueueMessage, ToolMessage}, }; use serde_json::Value; @@ -22,51 +23,47 @@ pub async fn call_tools( return Ok(None); } - if tools.len() > 1 { - eprintln!("模型一次返回了{}个工具调用,当前仅处理第一个", tools.len()); + let mut results = Vec::with_capacity(tools.len()); + for tool in tools { + let (tool_name, tool_result) = match tool.function.as_ref() { + Some(function) => { + println!( + "开始调用工具: {}, arguments={}", + function.name, function.arguments + ); + ( + function.name.clone(), + execute_tool(&channel, &tool_path, function).await, + ) + } + None => { + eprintln!("工具调用缺少 function 字段: id={}", tool.id); + ( + "unknown".to_string(), + "工具调用失败: tool call 缺少 function 字段".to_string(), + ) + } + }; + + let tool_result = if tool_result.trim().is_empty() { + format!("工具调用失败: {tool_name} 返回空结果") + } else { + tool_result + }; + println!( + "工具调用完成: {}, result_len={}", + tool_name, + tool_result.len() + ); + + results.push(ToolMessage { + tool_call_id: tool.id, + name: tool_name, + result: tool_result, + }); } - let Some(tool) = tools.first() else { - return Ok(None); - }; - - let (tool_name, tool_result) = match tool.function.as_ref() { - Some(function) => { - println!( - "开始调用工具: {}, arguments={}", - function.name, function.arguments - ); - ( - function.name.clone(), - execute_tool(&channel, &tool_path, function).await, - ) - } - None => { - eprintln!("工具调用缺少 function 字段: id={}", tool.id); - ( - "unknown".to_string(), - "工具调用失败: tool call 缺少 function 字段".to_string(), - ) - } - }; - - let tool_result = if tool_result.trim().is_empty() { - format!("工具调用失败: {tool_name} 返回空结果") - } else { - tool_result - }; - println!( - "工具调用完成: {}, result_len={}", - tool_name, - tool_result.len() - ); - - Ok(Some(QueueMessage::Tool( - channel, - tool.id.clone(), - tool_name, - tool_result, - ))) + Ok(Some(QueueMessage::Tools(channel, results))) } async fn execute_tool( @@ -83,7 +80,10 @@ async fn execute_tool( Ok(value) => value, Err(error) => format!("工具调用失败: 加载工具定义失败: {error}"), }, + "query_api_docs" => query_api_docs(&function.arguments).await, "create_web_page" => create_web_page(&function.arguments).await, + "create_markdown_preview" => create_markdown_preview(&function.arguments).await, + "query_web_pages" => query_web_pages(&function.arguments).await, "remember_memory" => remember_memory(channel, &function.arguments).await, "query_memory" => query_memory(channel, &function.arguments).await, "update_memory" => update_memory(channel, &function.arguments).await, @@ -137,7 +137,7 @@ pub fn get_tool() -> Vec { "type": "function", "function": { "name": "create_web_page", - "description": "创建一个可通过链接访问的动态内容页面,用于把较长或结构复杂的内容交给用户查看。邮件提醒请使用 page_type=mail,工具结果会返回 /mail/{slug} 链接。", + "description": "创建一个可通过链接访问的动态内容页面,用于把较长或结构复杂的内容交给用户查看。Markdown 长文本优先使用 create_markdown_preview;邮件提醒请使用 page_type=mail,工具结果会返回 /mail/{slug} 链接。", "parameters": { "type": "object", "properties": { @@ -174,6 +174,109 @@ pub fn get_tool() -> Vec { } } }), + serde_json::json!({ + "type": "function", + "function": { + "name": "create_markdown_preview", + "description": "创建 Markdown 预览页面,用于把长篇 Markdown、报告、代码说明、列表、表格或结构化答复做成可分享链接,避免 IM/平台消息长度限制。工具结果会返回 /md/{slug} 链接,最终回复用户时应发送该链接和简短摘要。", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Markdown 预览页标题。" + }, + "summary": { + "type": "string", + "description": "简短摘要,会显示在页面标题下方,也可用于最终回复用户。" + }, + "content": { + "type": "string", + "description": "Markdown 正文,支持标题、列表、表格、引用、链接、代码块等 GFM 语法。" + }, + "source_label": { + "type": "string", + "description": "内容来源,例如 AI 回复、报告名称或服务名。" + }, + "metadata": { + "type": "object", + "description": "可选附加结构化信息;系统会自动补充 content_format=markdown。" + }, + "text": { + "type": "string", + "description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。" + } + }, + "required": ["title", "content"] + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "query_web_pages", + "description": "查询已创建的动态 web 页面,并返回可直接发给用户的页面链接。用户询问之前生成的页面、邮件预览页面、页面链接或需要查找可发送页面时使用。", + "parameters": { + "type": "object", + "properties": { + "page_type": { + "type": "string", + "description": "可选页面类型,例如 page、mail、markdown;为空时查询所有类型。" + }, + "query": { + "type": "string", + "description": "可选关键词,会匹配标题、摘要、正文、来源和 metadata。" + }, + "page": { + "type": "integer", + "description": "可选页码,默认 1。" + }, + "per_page": { + "type": "integer", + "description": "可选每页数量,默认 10,最大 20。" + }, + "text": { + "type": "string", + "description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。" + } + }, + "required": [] + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "query_api_docs", + "description": "查询 iAs HTTP API 文档。用户询问接口路径、参数、请求体、响应格式、页面数据 API、邮件 API、长期记忆 API 或控制台 API 时使用。返回结果可直接用于中文回复用户。", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "可选关键词,会匹配接口 id、分组、路径、摘要、说明、参数、响应和备注。" + }, + "group": { + "type": "string", + "description": "可选分组,例如 api-docs、system、updates、web、mail、console、context。" + }, + "endpoint_id": { + "type": "string", + "description": "可选。查询单个接口详情,例如 web.pages.list、web.pages.create、mail.messages.get、context.memories.search。传入后会忽略 query/group。" + }, + "limit": { + "type": "integer", + "description": "可选。列表模式返回数量,默认 50,最大 100。" + }, + "text": { + "type": "string", + "description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。" + } + }, + "required": [] + } + } + }), serde_json::json!({ "type": "function", "function": { diff --git a/ias-ai/src/tools/api_docs.rs b/ias-ai/src/tools/api_docs.rs new file mode 100644 index 0000000..ac78982 --- /dev/null +++ b/ias-ai/src/tools/api_docs.rs @@ -0,0 +1,206 @@ +use anyhow::{Context, anyhow}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +struct QueryApiDocsArgs { + query: Option, + group: Option, + endpoint_id: Option, + limit: Option, + #[allow(dead_code)] + text: Option, +} + +pub async fn query_api_docs(arguments: &str) -> String { + match query_api_docs_inner(arguments).await { + Ok(result) => result, + Err(error) => format!("工具调用失败: 查询 API 文档失败: {error}"), + } +} + +async fn query_api_docs_inner(arguments: &str) -> anyhow::Result { + let args: QueryApiDocsArgs = + serde_json::from_str(arguments).context("解析 query_api_docs 参数失败")?; + + if let Some(endpoint_id) = normalized_text(args.endpoint_id) { + let endpoint_id = validate_endpoint_id(&endpoint_id)?; + let url = reqwest::Url::parse(&format!( + "{}/v1/api-docs/{}", + api_docs_base_url(), + endpoint_id + )) + .context("构造 API 文档详情地址失败")?; + let value = request_json(url).await?; + return format_doc_detail(&value); + } + + let mut url = reqwest::Url::parse(&format!("{}/v1/api-docs", api_docs_base_url())) + .context("构造 API 文档查询地址失败")?; + { + let mut query_pairs = url.query_pairs_mut(); + if let Some(query) = normalized_text(args.query) { + query_pairs.append_pair("q", &query); + } + if let Some(group) = normalized_text(args.group) { + query_pairs.append_pair("group", &group); + } + if let Some(limit) = args.limit { + query_pairs.append_pair("limit", &limit.to_string()); + } + } + + let value = request_json(url).await?; + format_doc_list(&value) +} + +async fn request_json(url: reqwest::Url) -> anyhow::Result { + let client = reqwest::Client::new(); + let response = client + .get(url.clone()) + .send() + .await + .with_context(|| format!("请求 API 文档接口失败: {url}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() { + return Err(anyhow!("接口返回失败: status={status}, body={body}")); + } + + serde_json::from_str(&body).context("解析 API 文档接口响应失败") +} + +fn format_doc_list(value: &Value) -> anyhow::Result { + let docs = value + .get("docs") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("接口响应缺少 docs 字段"))?; + if docs.is_empty() { + return Ok("没有查到匹配的 API 文档。".to_string()); + } + + let total = value + .get("total") + .and_then(Value::as_u64) + .unwrap_or(docs.len() as u64); + let lines = docs + .iter() + .filter_map(format_doc_brief) + .collect::>() + .join("\n"); + + Ok(format!( + "API 文档查询结果:共 {total} 条,返回 {} 条。\n{}\n需要完整请求体、响应示例或备注时,可用 endpoint_id 查询单个接口。", + docs.len(), + lines + )) +} + +fn format_doc_detail(value: &Value) -> anyhow::Result { + let doc = value + .get("doc") + .ok_or_else(|| anyhow!("接口响应缺少 doc 字段"))?; + let id = required_field(doc, "id")?; + let method = required_field(doc, "method")?; + let path = required_field(doc, "path")?; + let group = required_field(doc, "group")?; + let summary = required_field(doc, "summary")?; + let description = required_field(doc, "description")?; + let auth = required_field(doc, "auth")?; + let response = required_field(doc, "response")?; + + let mut sections = vec![ + format!("API 文档:{id}"), + format!("{method} {path}"), + format!("分组:{group}"), + format!("摘要:{summary}"), + format!("说明:{description}"), + format!("鉴权:{auth}"), + ]; + + append_list_section(&mut sections, "Path 参数", doc.get("path_params")); + append_list_section(&mut sections, "Query 参数", doc.get("query_params")); + + if let Some(body) = doc.get("body").and_then(Value::as_str) { + if !body.trim().is_empty() { + sections.push(format!("请求体:{body}")); + } + } + + sections.push(format!("响应示例:{response}")); + append_list_section(&mut sections, "备注", doc.get("notes")); + + Ok(sections.join("\n")) +} + +fn format_doc_brief(doc: &Value) -> Option { + let id = doc.get("id")?.as_str()?; + let method = doc.get("method")?.as_str()?; + let path = doc.get("path")?.as_str()?; + let summary = doc.get("summary")?.as_str()?; + let auth = doc + .get("auth") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("-"); + Some(format!( + "- [{id}] {method} {path}\n 摘要:{summary}\n 鉴权:{auth}" + )) +} + +fn append_list_section(sections: &mut Vec, title: &str, value: Option<&Value>) { + let items = value + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("- {value}")) + .collect::>() + }) + .unwrap_or_default(); + if !items.is_empty() { + sections.push(format!("{title}:\n{}", items.join("\n"))); + } +} + +fn required_field<'a>(doc: &'a Value, name: &str) -> anyhow::Result<&'a str> { + doc.get(name) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("接口文档缺少 {name} 字段")) +} + +fn normalized_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn validate_endpoint_id(value: &str) -> anyhow::Result { + if value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-' || byte == b'_') + { + Ok(value.to_string()) + } else { + Err(anyhow!( + "endpoint_id 只能包含字母、数字、点、下划线或连字符" + )) + } +} + +fn api_docs_base_url() -> String { + std::env::var("IA_API_DOCS_BASE_URL") + .or_else(|_| std::env::var("IA_WEB_BASE_URL")) + .or_else(|_| std::env::var("WEB_BASE_URL")) + .or_else(|_| std::env::var("HOST").map(|host| format!("http://{host}"))) + .unwrap_or_else(|_| "http://127.0.0.1:9003".to_string()) + .trim() + .trim_end_matches('/') + .replace("0.0.0.0", "127.0.0.1") +} diff --git a/ias-ai/src/tools/mod.rs b/ias-ai/src/tools/mod.rs index e9ce6ed..a3ba869 100644 --- a/ias-ai/src/tools/mod.rs +++ b/ias-ai/src/tools/mod.rs @@ -1,3 +1,4 @@ +pub mod api_docs; pub mod date_time; pub mod memory; pub mod web; diff --git a/ias-ai/src/tools/web.rs b/ias-ai/src/tools/web.rs index 6d277fa..0fcb0d3 100644 --- a/ias-ai/src/tools/web.rs +++ b/ias-ai/src/tools/web.rs @@ -14,6 +14,17 @@ struct CreateWebPageArgs { text: Option, } +#[derive(Debug, Deserialize)] +struct CreateMarkdownPreviewArgs { + title: String, + summary: Option, + content: String, + source_label: Option, + metadata: Option, + #[allow(dead_code)] + text: Option, +} + #[derive(Debug, Deserialize)] struct CreateWebPageResponse { success: bool, @@ -24,6 +35,35 @@ struct CreateWebPageResponse { url: String, } +#[derive(Debug, Deserialize)] +struct QueryWebPagesArgs { + page_type: Option, + query: Option, + page: Option, + per_page: Option, + #[allow(dead_code)] + text: Option, +} + +#[derive(Debug, Deserialize)] +struct QueryWebPagesResponse { + success: bool, + page: i64, + per_page: i64, + total: i64, + pages: Vec, +} + +#[derive(Debug, Deserialize)] +struct WebPageItem { + slug: String, + page_type: String, + title: String, + summary: Option, + source_label: Option, + created_at: Option, +} + pub async fn create_web_page(arguments: &str) -> String { match create_web_page_inner(arguments).await { Ok(result) => result, @@ -31,6 +71,20 @@ pub async fn create_web_page(arguments: &str) -> String { } } +pub async fn create_markdown_preview(arguments: &str) -> String { + match create_markdown_preview_inner(arguments).await { + Ok(result) => result, + Err(error) => format!("工具调用失败: 创建 Markdown 预览页面失败: {error}"), + } +} + +pub async fn query_web_pages(arguments: &str) -> String { + match query_web_pages_inner(arguments).await { + Ok(result) => result, + Err(error) => format!("工具调用失败: 查询 web 页面失败: {error}"), + } +} + async fn create_web_page_inner(arguments: &str) -> anyhow::Result { let args: CreateWebPageArgs = serde_json::from_str(arguments).context("解析 create_web_page 参数失败")?; @@ -41,14 +95,40 @@ async fn create_web_page_inner(arguments: &str) -> anyhow::Result { return Err(anyhow!("content 不能为空")); } - let payload = json!({ + create_page_request(json!({ "page_type": args.page_type, "title": args.title, "summary": args.summary, "content": args.content, "source_label": args.source_label, "metadata": args.metadata, - }); + })) + .await +} + +async fn create_markdown_preview_inner(arguments: &str) -> anyhow::Result { + let args: CreateMarkdownPreviewArgs = + serde_json::from_str(arguments).context("解析 create_markdown_preview 参数失败")?; + if args.title.trim().is_empty() { + return Err(anyhow!("title 不能为空")); + } + if args.content.trim().is_empty() { + return Err(anyhow!("content 不能为空")); + } + + let metadata = markdown_metadata(args.metadata); + create_page_request(json!({ + "page_type": "markdown", + "title": args.title, + "summary": args.summary, + "content": args.content, + "source_label": args.source_label, + "metadata": metadata, + })) + .await +} + +async fn create_page_request(payload: Value) -> anyhow::Result { let base_url = web_base_url(); let endpoint = format!("{base_url}/v1/web/pages"); let client = reqwest::Client::new(); @@ -84,6 +164,117 @@ async fn create_web_page_inner(arguments: &str) -> anyhow::Result { )) } +fn markdown_metadata(metadata: Option) -> Value { + let mut value = metadata.unwrap_or_else(|| json!({})); + let Value::Object(map) = &mut value else { + return json!({ + "content_format": "markdown", + "preview_type": "markdown", + }); + }; + + map.insert("content_format".to_string(), json!("markdown")); + map.insert("preview_type".to_string(), json!("markdown")); + value +} + +async fn query_web_pages_inner(arguments: &str) -> anyhow::Result { + let args: QueryWebPagesArgs = + serde_json::from_str(arguments).context("解析 query_web_pages 参数失败")?; + let page = args.page.unwrap_or(1).max(1); + let per_page = args.per_page.unwrap_or(10).clamp(1, 20); + + let mut url = reqwest::Url::parse(&format!("{}/v1/web/pages", web_base_url())) + .context("构造 web 页面查询地址失败")?; + { + let mut query_pairs = url.query_pairs_mut(); + query_pairs.append_pair("page", &page.to_string()); + query_pairs.append_pair("per_page", &per_page.to_string()); + if let Some(page_type) = optional_text(args.page_type) { + query_pairs.append_pair("page_type", &page_type); + } + if let Some(query) = optional_text(args.query) { + query_pairs.append_pair("q", &query); + } + } + + let client = reqwest::Client::new(); + let response = client + .get(url.clone()) + .send() + .await + .with_context(|| format!("请求 web 页面列表接口失败: {url}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(anyhow!("接口返回失败: status={status}, body={body}")); + } + + let result: QueryWebPagesResponse = + serde_json::from_str(&body).context("解析 web 页面列表接口响应失败")?; + if !result.success { + return Err(anyhow!("接口未成功查询页面: {body}")); + } + if result.pages.is_empty() { + return Ok("没有查到匹配的 web 页面。".to_string()); + } + + let pages = result + .pages + .iter() + .map(format_web_page_item) + .collect::>() + .join("\n"); + + Ok(format!( + "查到 {} 个匹配页面,本次返回 {} 个(第 {} 页,每页 {} 个):\n{}", + result.total, + result.pages.len(), + result.page, + result.per_page, + pages + )) +} + +fn format_web_page_item(page: &WebPageItem) -> String { + let url = web_page_url(page); + let summary = page + .summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("-"); + let source = page + .source_label + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("-"); + let created_at = page.created_at.as_deref().unwrap_or("-"); + + format!( + "- {}\n 链接:{}\n 类型:{} 来源:{} 创建时间:{}\n 摘要:{}", + page.title, url, page.page_type, source, created_at, summary + ) +} + +fn web_page_url(page: &WebPageItem) -> String { + let path = if page.page_type == "mail" { + format!("/mail/{}", page.slug) + } else if page.page_type == "markdown" || page.page_type == "md" { + format!("/md/{}", page.slug) + } else { + format!("/web/{}/{}", page.page_type, page.slug) + }; + format!("{}{}", web_base_url(), path) +} + +fn optional_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn web_base_url() -> String { std::env::var("IA_WEB_BASE_URL") .or_else(|_| std::env::var("WEB_BASE_URL")) diff --git a/ias-common/Cargo.toml b/ias-common/Cargo.toml index a617deb..d389b5c 100644 --- a/ias-common/Cargo.toml +++ b/ias-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-common" -version = "0.1.0" +version = "0.1.1" edition = "2024" [dependencies] diff --git a/ias-common/src/queue.rs b/ias-common/src/queue.rs index 3ebfd94..2c825e1 100644 --- a/ias-common/src/queue.rs +++ b/ias-common/src/queue.rs @@ -8,6 +8,7 @@ pub enum QueueMessage { Notice(ChannelMessage, String), Aissitant(ChannelMessage, AssistantMessage), Tool(ChannelMessage, String, String, String), + Tools(ChannelMessage, Vec), } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/ias-context/Cargo.toml b/ias-context/Cargo.toml index facbd97..ff50bb4 100644 --- a/ias-context/Cargo.toml +++ b/ias-context/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-context" -version = "0.1.1" +version = "0.1.2" edition = "2024" [dependencies] diff --git a/ias-context/src/manager.rs b/ias-context/src/manager.rs index 8430b9f..a1c0f57 100644 --- a/ias-context/src/manager.rs +++ b/ias-context/src/manager.rs @@ -6,6 +6,7 @@ use ias_common::model::ai::{ }; use ias_common::queue::ChannelMessage; use sqlx::PgPool; +use std::collections::HashSet; use crate::model::{ContextMessageRow, ContextScope, ContextSession, LongTermMemory}; use crate::repository::ContextRepository; @@ -112,6 +113,7 @@ impl ContextManager { let session = self.active_or_new_session(&scope).await?; let tool_calls = tool_calls .as_ref() + .filter(|calls| !calls.is_empty()) .map(serde_json::to_string) .transpose() .context("序列化 assistant tool_calls 失败")?; @@ -285,6 +287,7 @@ impl ContextManager { messages.push(row_to_chat_message(row)?); } + let messages = sanitize_tool_call_messages(messages); Ok(ContextSnapshot { messages }) } @@ -448,7 +451,8 @@ fn row_to_chat_message(row: ContextMessageRow) -> anyhow::Result>) .transpose() - .context("解析历史 tool_calls 失败")?; + .context("解析历史 tool_calls 失败")? + .filter(|calls| !calls.is_empty()); Ok(ChatCompletionRequestMessage { role: row.role, @@ -458,6 +462,74 @@ fn row_to_chat_message(row: ContextMessageRow) -> anyhow::Result, +) -> Vec { + let mut sanitized = Vec::with_capacity(messages.len()); + let mut index = 0; + + while index < messages.len() { + let message = &messages[index]; + let tool_calls = message + .tool_calls + .as_ref() + .filter(|calls| !calls.is_empty()); + + if message.role == "assistant" + && let Some(tool_calls) = tool_calls + { + let expected_ids = tool_calls + .iter() + .map(|call| call.id.as_str()) + .collect::>(); + let mut seen_ids = HashSet::new(); + let mut tool_messages = Vec::new(); + let mut next = index + 1; + + while next < messages.len() && messages[next].role == "tool" { + if let Some(tool_call_id) = messages[next].tool_call_id.as_deref() + && expected_ids.contains(tool_call_id) + && seen_ids.insert(tool_call_id) + { + tool_messages.push(messages[next].clone()); + } + next += 1; + } + + if seen_ids.len() == expected_ids.len() { + sanitized.push(message.clone()); + sanitized.extend(tool_messages); + } else { + let content = message.content.trim(); + if !content.is_empty() { + let mut assistant = message.clone(); + assistant.tool_calls = None; + sanitized.push(assistant); + } + eprintln!( + "跳过不完整 assistant tool_calls 历史片段: expected={}, actual={}", + expected_ids.len(), + seen_ids.len() + ); + } + + index = next; + continue; + } + + if message.role == "tool" { + eprintln!("跳过没有对应 assistant tool_calls 的历史 tool 消息"); + index += 1; + continue; + } + + sanitized.push(message.clone()); + index += 1; + } + + sanitized +} + fn build_session_summary( session: &ContextSession, messages: &[ContextMessageRow], @@ -731,7 +803,16 @@ fn read_i64_env(name: &str, default: i64) -> i64 { #[cfg(test)] mod tests { - use super::detect_assistant_name_preference; + use super::{detect_assistant_name_preference, sanitize_tool_call_messages}; + use ias_common::model::ai::{ChatCompletionRequestMessage, ChatCompletionToolCall}; + + fn tool_call(id: &str) -> ChatCompletionToolCall { + ChatCompletionToolCall { + function: None, + id: id.to_string(), + call_type: "function".to_string(), + } + } #[test] fn detects_assistant_name_preference() { @@ -754,4 +835,53 @@ mod tests { assert_eq!(detect_assistant_name_preference("不要叫知微"), None); assert_eq!(detect_assistant_name_preference("你是谁?"), None); } + + #[test] + fn keeps_complete_multi_tool_call_sequence() { + let messages = sanitize_tool_call_messages(vec![ + ChatCompletionRequestMessage::new("user".to_string(), "查一下".to_string()), + ChatCompletionRequestMessage::assistant( + "assistant".to_string(), + String::new(), + Some(vec![tool_call("call_1"), tool_call("call_2")]), + ), + ChatCompletionRequestMessage::tool( + "tool".to_string(), + "结果 1".to_string(), + "call_1".to_string(), + ), + ChatCompletionRequestMessage::tool( + "tool".to_string(), + "结果 2".to_string(), + "call_2".to_string(), + ), + ]); + + assert_eq!(messages.len(), 4); + assert_eq!(messages[1].tool_calls.as_ref().unwrap().len(), 2); + assert_eq!(messages[2].tool_call_id.as_deref(), Some("call_1")); + assert_eq!(messages[3].tool_call_id.as_deref(), Some("call_2")); + } + + #[test] + fn drops_incomplete_tool_call_sequence() { + let messages = sanitize_tool_call_messages(vec![ + ChatCompletionRequestMessage::new("user".to_string(), "查一下".to_string()), + ChatCompletionRequestMessage::assistant( + "assistant".to_string(), + String::new(), + Some(vec![tool_call("call_1"), tool_call("call_2")]), + ), + ChatCompletionRequestMessage::tool( + "tool".to_string(), + "结果 1".to_string(), + "call_1".to_string(), + ), + ChatCompletionRequestMessage::new("user".to_string(), "继续".to_string()), + ]); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, "user"); + assert_eq!(messages[1].content, "继续"); + } } diff --git a/ias-http/Cargo.toml b/ias-http/Cargo.toml index 074fc9d..890919b 100644 --- a/ias-http/Cargo.toml +++ b/ias-http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ias-http" -version = "0.1.4" +version = "0.1.9" edition = "2024" [dependencies] @@ -16,5 +16,5 @@ sqlx = { version = "0.9.0", features = [ "chrono", ] } tokio = { version = "1.0", features = ["net", "sync"] } -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { version = "0.6", features = ["cors", "fs"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/ias-http/src/api_docs.rs b/ias-http/src/api_docs.rs new file mode 100644 index 0000000..1c17ee5 --- /dev/null +++ b/ias-http/src/api_docs.rs @@ -0,0 +1,452 @@ +use axum::extract::{Path, Query}; +use axum::http::StatusCode; +use axum::routing::get; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +type ApiError = (StatusCode, Json); + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct ApiDoc { + pub id: &'static str, + pub group: &'static str, + pub method: &'static str, + pub path: &'static str, + pub summary: &'static str, + pub description: &'static str, + pub auth: &'static str, + pub query_params: &'static [&'static str], + pub path_params: &'static [&'static str], + pub body: Option<&'static str>, + pub response: &'static str, + pub notes: &'static [&'static str], +} + +#[derive(Debug, Deserialize)] +struct ApiDocQuery { + q: Option, + group: Option, + limit: Option, +} + +pub fn routes() -> Router +where + S: Clone + Send + Sync + 'static, +{ + Router::new() + .route("/v1/api-docs", get(list_api_docs)) + .route("/v1/api-docs/{id}", get(get_api_doc)) +} + +async fn list_api_docs(Query(query): Query) -> Json { + let q = normalized_optional(query.q); + let group = normalized_optional(query.group); + let limit = query.limit.unwrap_or(50).clamp(1, 100); + + let mut docs = api_docs() + .into_iter() + .filter(|doc| { + group + .as_deref() + .map_or(true, |group| doc.group.eq_ignore_ascii_case(group)) + }) + .filter(|doc| q.as_deref().map_or(true, |q| doc_matches(doc, q))) + .collect::>(); + let total = docs.len(); + docs.truncate(limit); + + Json(json!({ + "success": true, + "total": total, + "limit": limit, + "docs": docs, + })) +} + +async fn get_api_doc(Path(id): Path) -> Result, ApiError> { + let doc = api_docs() + .into_iter() + .find(|doc| doc.id == id.trim()) + .ok_or_else(|| api_not_found("API 文档不存在"))?; + + Ok(Json(json!({ + "success": true, + "doc": doc, + }))) +} + +fn api_docs() -> Vec { + vec![ + ApiDoc { + id: "health.get", + group: "system", + method: "GET", + path: "/v1/health", + summary: "检查 HTTP 服务是否运行", + description: "返回服务存活状态,适合健康检查和本地连通性验证。", + auth: "无", + query_params: &[], + path_params: &[], + body: None, + response: r#"{"success":true,"message":"service is running"}"#, + notes: &["该接口只表示 HTTP 服务可访问,不代表数据库、邮件或外部模型均可用。"], + }, + ApiDoc { + id: "updates.list", + group: "updates", + method: "GET", + path: "/v1/updates", + summary: "查询升级日志列表", + description: "按发布时间和版本倒序返回 ias_upgrade_logs 中的升级记录。", + auth: "无", + query_params: &[], + path_params: &[], + body: None, + response: r#"{"success":true,"logs":[{"version":"0.2.14","released_at":"...","title":"...","description":"...","changes":{...}}]}"#, + notes: &["升级记录由服务迁移写入,内容应与 CHANGELOG.md 保持一致。"], + }, + ApiDoc { + id: "updates.get", + group: "updates", + method: "GET", + path: "/v1/updates/{version}", + summary: "查询单个版本的升级日志", + description: "根据版本号返回对应升级记录。", + auth: "无", + query_params: &[], + path_params: &["version: 版本号,例如 0.2.14"], + body: None, + response: r#"{"success":true,"log":{"version":"0.2.14","released_at":"...","title":"...","description":"...","changes":{...}}}"#, + notes: &["版本不存在时返回 404。"], + }, + ApiDoc { + id: "api-docs.list", + group: "api-docs", + method: "GET", + path: "/v1/api-docs", + summary: "搜索或列出 HTTP API 文档", + description: "查询随服务发布的结构化 API 文档,供控制台、调试器和 AI 工具使用。", + auth: "无", + query_params: &[ + "q: 可选关键词,会匹配 id、分组、路径、摘要、说明、参数、响应和备注", + "group: 可选分组,例如 web、mail、context、updates、console", + "limit: 可选返回数量,默认 50,范围 1..100", + ], + path_params: &[], + body: None, + response: r#"{"success":true,"total":12,"limit":50,"docs":[{"id":"web.pages.list","method":"GET","path":"/v1/web/pages",...}]}"#, + notes: &["该接口只返回 API 文档,不返回 React 页面内容。"], + }, + ApiDoc { + id: "api-docs.get", + group: "api-docs", + method: "GET", + path: "/v1/api-docs/{id}", + summary: "查询单个 HTTP API 文档", + description: "根据 endpoint id 返回完整 API 文档。", + auth: "无", + query_params: &[], + path_params: &["id: API 文档 id,例如 web.pages.list"], + body: None, + response: r#"{"success":true,"doc":{"id":"web.pages.list","method":"GET","path":"/v1/web/pages",...}}"#, + notes: &["id 不存在时返回 404。"], + }, + ApiDoc { + id: "web.pages.list", + group: "web", + method: "GET", + path: "/v1/web/pages", + summary: "查询动态页面列表", + description: "返回由 create_web_page 或邮件预览创建的动态页面记录,支持按类型、关键词和分页查询。", + auth: "无", + query_params: &[ + "page_type: 可选页面类型,例如 page、mail", + "q: 可选关键词,匹配 title、summary、content、source_label、metadata", + "page: 可选页码,默认 1", + "per_page: 可选每页数量,默认 20,范围 1..100", + ], + path_params: &[], + body: None, + response: r#"{"success":true,"page":1,"per_page":20,"total":1,"pages":[{"id":1,"slug":"...","page_type":"page","title":"...","content":"..."}]}"#, + notes: &[ + "该接口只返回页面数据;页面展示由 React 前端负责。", + "用户可访问的页面路径通常为 /web/{page_type}/{slug}、/mail/{slug} 或 /md/{slug}。", + ], + }, + ApiDoc { + id: "web.pages.create", + group: "web", + method: "POST", + path: "/v1/web/pages", + summary: "创建动态内容页面", + description: "创建一条可由 React 前端展示的动态页面记录,适合把长文本或结构化内容转成可分享链接。", + auth: "无", + query_params: &[], + path_params: &[], + body: Some( + r#"{"page_type":"page","title":"标题","summary":"摘要","content":"正文","source_label":"来源","metadata":{}}"#, + ), + response: r#"{"success":true,"id":1,"slug":"...","page_type":"page","title":"标题","summary":"摘要","path":"/web/page/...","url":"http://.../web/page/..."}"#, + notes: &[ + "page_type 只能包含字母、数字、下划线或连字符。", + "page_type=markdown 时公开路径为 /md/{slug},React 前端会按 Markdown 预览渲染。", + "title 与 content 必填且不能为空。", + ], + }, + ApiDoc { + id: "web.pages.get", + group: "web", + method: "GET", + path: "/v1/web/pages/{page_type}/{slug}", + summary: "读取单个动态页面", + description: "按页面类型和 slug 返回动态页面数据,供 React 页面详情页加载。", + auth: "无", + query_params: &[], + path_params: &["page_type: 页面类型", "slug: 页面 slug"], + body: None, + response: r#"{"success":true,"page":{"id":1,"slug":"...","page_type":"page","title":"...","content":"..."}}"#, + notes: &["页面不存在时返回 404。"], + }, + ApiDoc { + id: "web.mail.create", + group: "web", + method: "POST", + path: "/v1/web/mail", + summary: "创建邮件预览页面", + description: "把邮件信息写入动态页面表,生成 /mail/{slug} 预览链接。", + auth: "无", + query_params: &[], + path_params: &[], + body: Some( + r#"{"from":"发件人","subject":"主题","reminder":"提醒摘要","content":"邮件 HTML 或文本","received_at":"收到时间"}"#, + ), + response: r#"{"success":true,"id":1,"slug":"...","page_type":"mail","title":"主题","path":"/mail/...","url":"http://.../mail/..."}"#, + notes: &["邮件预览内容在 React 前端中以沙箱 iframe 展示。"], + }, + ApiDoc { + id: "web.mail.get", + group: "web", + method: "GET", + path: "/v1/web/mail/{slug}", + summary: "读取邮件预览页面", + description: "按 slug 返回 page_type=mail 的动态页面数据。", + auth: "无", + query_params: &[], + path_params: &["slug: 邮件预览页面 slug"], + body: None, + response: r#"{"success":true,"page":{"id":1,"slug":"...","page_type":"mail","title":"...","content":"..."}}"#, + notes: &["页面不存在时返回 404。"], + }, + ApiDoc { + id: "mail.messages.list", + group: "mail", + method: "GET", + path: "/v1/mail/messages", + summary: "查询邮件记录列表", + description: "分页查询已拉取的邮件记录,也可通过 /v1/mail/search 使用相同参数查询。", + auth: "无", + query_params: &[ + "q: 可选关键词,匹配发件人、主题、摘要、正文预览等", + "account_id: 可选邮件账号 id", + "mailbox: 可选邮箱目录", + "source_key: 可选邮件来源标识", + "seen: 可选是否已读", + "page: 可选页码", + "per_page: 可选每页数量", + "limit: 可选返回数量,兼容旧查询", + ], + path_params: &[], + body: None, + response: r#"{"success":true,"page":1,"per_page":20,"total":1,"messages":[{"id":1,"from_label":"...","subject":"...","summary":"..."}]}"#, + notes: &["/v1/mail/search 是该接口的别名。"], + }, + ApiDoc { + id: "mail.messages.get", + group: "mail", + method: "GET", + path: "/v1/mail/messages/{id}", + summary: "读取单封邮件记录", + description: "返回单封邮件详情;如果邮件尚未标记已读,读取时会尝试标记为已读。", + auth: "无", + query_params: &[], + path_params: &["id: 邮件记录 id"], + body: None, + response: r#"{"success":true,"message":{"id":1,"from_label":"...","subject":"...","content":"...","seen":true}}"#, + notes: &["邮件不存在时返回 404。"], + }, + ApiDoc { + id: "console.tools.list", + group: "console", + method: "GET", + path: "/v1/tools", + summary: "查询可用工具列表", + description: "返回内置 AI 工具和外部 capability 工具的名称、参数、来源和警告信息。", + auth: "无", + query_params: &[], + path_params: &[], + body: None, + response: r#"{"success":true,"total":1,"tools_path":"...","builtin_tools":[...],"capability_tools":[...],"warnings":[]}"#, + notes: &["该接口用于控制台展示和调试工具能力。"], + }, + ApiDoc { + id: "console.user-configs.list", + group: "console", + method: "GET", + path: "/v1/user-configs", + summary: "查询用户侧配置概览", + description: "返回微信账号和邮件账号配置的非敏感概览,密码或 token 只返回是否已配置。", + auth: "无", + query_params: &[], + path_params: &[], + body: None, + response: r#"{"success":true,"wechat_total":1,"mail_total":1,"wechat_accounts":[...],"mail_accounts":[...]}"#, + notes: &["不会返回明文 token 或邮箱密码。"], + }, + ApiDoc { + id: "context.memories.create", + group: "context", + method: "POST", + path: "/v1/context/memories", + summary: "写入长期记忆", + description: "在指定 channel、account_id 和 from_user_id 作用域下保存长期记忆。", + auth: "需要 x-ias-context-token 或 Authorization: Bearer,与 IA_CONTEXT_API_KEY/IA_CONTEXT_TOKEN/ROUTER_API_KEY 匹配", + query_params: &[], + path_params: &[], + body: Some( + r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{},"content":"记忆内容","metadata":{}}"#, + ), + response: r#"{"success":true,"memory":{"id":1,"scope_key":"wechat:...","content":"...","metadata":{},"created_at":"...","updated_at":"..."}}"#, + notes: &["content 必填且不能为空。"], + }, + ApiDoc { + id: "context.memories.search", + group: "context", + method: "POST", + path: "/v1/context/memories/search", + summary: "查询长期记忆", + description: "在指定作用域下按关键词查询长期记忆;关键词为空时返回最近更新的记忆。", + auth: "需要 x-ias-context-token 或 Authorization: Bearer", + query_params: &[], + path_params: &[], + body: Some( + r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{},"query":"关键词","limit":20}"#, + ), + response: r#"{"success":true,"memories":[{"id":1,"content":"...","created_at":"...","updated_at":"..."}]}"#, + notes: &["limit 默认 20,范围 1..50。"], + }, + ApiDoc { + id: "context.memories.update", + group: "context", + method: "PATCH", + path: "/v1/context/memories/{id}", + summary: "更新长期记忆", + description: "在当前作用域内更新指定长期记忆的内容和可选 metadata。", + auth: "需要 x-ias-context-token 或 Authorization: Bearer", + query_params: &[], + path_params: &["id: 长期记忆 id"], + body: Some( + r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{},"content":"新内容","metadata":{}}"#, + ), + response: r#"{"success":true,"memory":{"id":1,"content":"新内容","updated_at":"..."}}"#, + notes: &["content 必填且不能为空;记忆不存在时返回 404。"], + }, + ApiDoc { + id: "context.memories.delete", + group: "context", + method: "DELETE", + path: "/v1/context/memories/{id}", + summary: "删除长期记忆", + description: "在当前作用域内软删除指定长期记忆。", + auth: "需要 x-ias-context-token 或 Authorization: Bearer", + query_params: &[], + path_params: &["id: 长期记忆 id"], + body: Some( + r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{}}"#, + ), + response: r#"{"success":true,"deleted":true}"#, + notes: &["找不到可删除记忆时 deleted 为 false。"], + }, + ApiDoc { + id: "context.simulator.get", + group: "context", + method: "GET", + path: "/v1/context/simulator", + summary: "查询上下文模拟器数据", + description: "返回调试上下文所需的工具列表、默认消息、会话、消息、记忆和摘要数据。", + auth: "无", + query_params: &[ + "channel: 可选渠道,默认 wechat", + "account_id: 可选账号 id", + "from_user_id: 可选用户 id", + ], + path_params: &[], + body: None, + response: r#"{"success":true,"has_query":true,"scope_key":"wechat:...","tools":[...],"default_messages":[...],"sessions":[...],"messages":[...],"memories":[...],"summaries":[...]}"#, + notes: &["未提供 account_id/from_user_id 时只返回默认调试数据。"], + }, + ] +} + +fn normalized_optional(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn doc_matches(doc: &ApiDoc, query: &str) -> bool { + let query = query.to_ascii_lowercase(); + let fields = [ + doc.id, + doc.group, + doc.method, + doc.path, + doc.summary, + doc.description, + doc.auth, + doc.body.unwrap_or_default(), + doc.response, + ]; + + fields + .iter() + .any(|field| field.to_ascii_lowercase().contains(&query)) + || doc + .query_params + .iter() + .chain(doc.path_params.iter()) + .chain(doc.notes.iter()) + .any(|field| field.to_ascii_lowercase().contains(&query)) +} + +fn api_not_found(message: impl Into) -> ApiError { + ( + StatusCode::NOT_FOUND, + Json(json!({ + "success": false, + "message": message.into(), + })), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docs_include_searchable_web_page_query() { + let doc = api_docs() + .into_iter() + .find(|doc| doc.id == "web.pages.list") + .expect("web pages list doc exists"); + + assert!(doc.query_params.iter().any(|param| param.starts_with("q:"))); + assert!(doc_matches(&doc, "metadata")); + } + + #[test] + fn docs_include_self_documentation() { + assert!(api_docs().iter().any(|doc| doc.id == "api-docs.list")); + assert!(api_docs().iter().any(|doc| doc.id == "api-docs.get")); + } +} diff --git a/ias-http/src/app.rs b/ias-http/src/app.rs index 911795e..9583590 100644 --- a/ias-http/src/app.rs +++ b/ias-http/src/app.rs @@ -1,10 +1,12 @@ -use axum::response::Redirect; -use axum::routing::get; +use axum::http::StatusCode; +use axum::routing::{any, get}; use axum::{Json, Router}; use sqlx::PgPool; use tokio::sync::watch; use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; +use crate::api_docs::routes as api_doc_routes; use crate::updates::routes as update_routes; use crate::web::routes as web_routes; @@ -19,12 +21,17 @@ pub async fn create_http( .allow_methods(Any) .allow_headers(Any); + let spa_assets = ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html")); + let app = Router::new() - .route("/", get(home)) .route("/v1/health", get(health)) + .merge(api_doc_routes::<()>()) .merge(update_routes::<()>(db.clone())) .merge(web_routes::routes::<()>(db)) .merge(extra_routes) + .route("/v1", any(api_not_found)) + .route("/v1/{*path}", any(api_not_found)) + .fallback_service(spa_assets) .layer(cors); let listener = tokio::net::TcpListener::bind(&host).await.map_err(|err| { @@ -50,13 +57,33 @@ pub async fn create_http( Ok(()) } -async fn home() -> Redirect { - Redirect::permanent("/updates") -} - async fn health() -> Json { Json(serde_json::json!({ "success": true, "message": "service is running" })) } + +async fn api_not_found() -> (StatusCode, Json) { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "success": false, + "message": "接口不存在" + })), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_catch_all_can_coexist_with_specific_v1_routes() { + let _router: Router = Router::new() + .route("/v1/health", get(health)) + .route("/v1/updates/{version}", get(api_not_found)) + .route("/v1", any(api_not_found)) + .route("/v1/{*path}", any(api_not_found)); + } +} diff --git a/ias-http/src/lib.rs b/ias-http/src/lib.rs index e9e12e9..68c15bd 100644 --- a/ias-http/src/lib.rs +++ b/ias-http/src/lib.rs @@ -1,5 +1,5 @@ +pub mod api_docs; pub mod app; -pub mod shell; pub mod updates; pub mod web; diff --git a/ias-http/src/shell.rs b/ias-http/src/shell.rs deleted file mode 100644 index 2cebd49..0000000 --- a/ias-http/src/shell.rs +++ /dev/null @@ -1,50 +0,0 @@ -/// 公共 HTML shell:Tailwind CSS CDN -pub fn html_shell(title: &str, body: &str) -> String { - format!( - r#" - - - - - {title} - - - - -
-
- iAs - -
-
-
- {body} -
- -"#, - title = escape_html(title), - body = body - ) -} - -pub fn escape_html(value: &str) -> String { - let mut escaped = String::with_capacity(value.len()); - for ch in value.chars() { - match ch { - '&' => escaped.push_str("&"), - '<' => escaped.push_str("<"), - '>' => escaped.push_str(">"), - '"' => escaped.push_str("""), - '\'' => escaped.push_str("'"), - _ => escaped.push(ch), - } - } - escaped -} diff --git a/ias-http/src/updates.rs b/ias-http/src/updates.rs index f32f777..29f8e3d 100644 --- a/ias-http/src/updates.rs +++ b/ias-http/src/updates.rs @@ -1,23 +1,21 @@ use axum::extract::Path; use axum::http::StatusCode; -use axum::response::Html; use axum::routing::get; -use axum::{Extension, Router}; +use axum::{Extension, Json, Router}; use chrono::{DateTime, Utc}; -use serde_json::Value; +use serde::Serialize; +use serde_json::{Value, json}; use sqlx::PgPool; use sqlx::prelude::FromRow; -use crate::shell::{escape_html, html_shell}; - -type HtmlError = (StatusCode, Html); +type ApiError = (StatusCode, Json); #[derive(Clone)] struct UpdateRouteState { db: PgPool, } -#[derive(Debug, Clone, FromRow)] +#[derive(Debug, Clone, FromRow, Serialize)] struct UpgradeLog { version: String, released_at: DateTime, @@ -26,36 +24,50 @@ struct UpgradeLog { changes: Value, } +// ── 路由 ──────────────────────────────────────────────── + pub fn routes(db: PgPool) -> Router where S: Clone + Send + Sync + 'static, { Router::new() - .route("/updates", get(show_updates)) - .route("/updates/{version}", get(show_update)) + .route("/v1/updates", get(list_updates_api)) + .route("/v1/updates/{version}", get(get_update_api)) .layer(Extension(UpdateRouteState { db })) } -async fn show_updates( +// ── JSON API ───────────────────────────────────────────── + +async fn list_updates_api( Extension(state): Extension, -) -> Result, HtmlError> { +) -> Result, ApiError> { let logs = list_upgrade_logs(&state.db) .await - .map_err(html_internal_error)?; - Ok(Html(render_updates_page(&logs))) + .map_err(api_internal_error)?; + + Ok(Json(json!({ + "success": true, + "logs": logs, + }))) } -async fn show_update( +async fn get_update_api( Extension(state): Extension, Path(version): Path, -) -> Result, HtmlError> { +) -> Result, ApiError> { let log = find_upgrade_log(&state.db, &version) .await - .map_err(html_internal_error)? - .ok_or_else(html_not_found)?; - Ok(Html(render_update_page(&log))) + .map_err(api_internal_error)? + .ok_or_else(|| api_not_found("更新日志不存在"))?; + + Ok(Json(json!({ + "success": true, + "log": log, + }))) } +// ── 数据库查询 ────────────────────────────────────────── + async fn list_upgrade_logs(pool: &PgPool) -> anyhow::Result> { let rows = sqlx::query_as::<_, UpgradeLog>( r#" @@ -83,113 +95,25 @@ async fn find_upgrade_log(pool: &PgPool, version: &str) -> anyhow::Result