feat: AI 工具架构重构与微信增强 (0.2.17 → 0.2.22)
0.2.17 收束模型初始工具列表: - 仅暴露 query_capabilities/query_capability_detail/call_capability 三个入口 - API 能力统一通过 iAs HTTP API 封装,删除重复 YAML 工具 0.2.18 系统提示词拆分: - 固定系统提示词硬编码,动态附加部分 AI 可维护 - 新增 update_additional_system_prompt 能力和持久化 0.2.19 上下文增强与打断语义: - /new 拥有最高打断语义,取消运行中任务并清除当前 turn - raw_message JSONB 原文入库,群聊按 group_id 隔离作用域 - 工具调用说明替代动态附加系统提示词 0.2.20 微信输入中状态: - 改用官方 getconfig + sendtyping 流程,显式管理 typing 状态 0.2.21 API 文档功能分组: - /v1/api-docs 返回 groups,帮助 AI 按功能面发现接口 0.2.22 AI API 能力去重: - 移除与 HTTP endpoint 重复的专用能力,统一用 query_api_docs + call_http_api - 删除不再编译的 web/memory 工具文件
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ias-ai"
|
||||
version = "0.1.6"
|
||||
version = "0.1.11"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+3
-1
@@ -23,7 +23,8 @@ pub async fn send_message(
|
||||
};
|
||||
|
||||
for choice in result.choices {
|
||||
let messsage_content = choice.message.content;
|
||||
let raw_message = serde_json::to_value(&choice.message).ok();
|
||||
let messsage_content = choice.message.content.clone().unwrap_or_default();
|
||||
let messsage_reasoning = choice.message.reasoning_content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.clone();
|
||||
|
||||
@@ -34,6 +35,7 @@ pub async fn send_message(
|
||||
reasoning: Some(messsage_reasoning),
|
||||
content: messsage_content,
|
||||
tool_calls,
|
||||
raw_message,
|
||||
},
|
||||
))
|
||||
.await
|
||||
|
||||
+401
-283
@@ -1,16 +1,64 @@
|
||||
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_markdown_preview, create_web_page, query_web_pages},
|
||||
workflow::core::{call_flow, load_tools_define},
|
||||
tools::http_api::call_http_api,
|
||||
tools::tool_instructions::query_tool_call_instruction,
|
||||
workflow::{
|
||||
core::{call_flow, load_configs},
|
||||
types::{FlowConfig, FlowParam},
|
||||
},
|
||||
};
|
||||
|
||||
use ias_common::{
|
||||
model::ai::{ChatCompletionFunction, ChatCompletionToolCall},
|
||||
queue::{ChannelMessage, QueueMessage, ToolMessage},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const CATEGORY_API: &str = "api";
|
||||
const CATEGORY_HTTP: &str = "http";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CapabilityInventory {
|
||||
pub api_tools: Vec<CapabilityDefinition>,
|
||||
pub http_tools: Vec<CapabilityDefinition>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CapabilityDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub source: String,
|
||||
pub path: Option<String>,
|
||||
pub params: Vec<CapabilityParam>,
|
||||
pub required_env: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CapabilityParam {
|
||||
pub name: String,
|
||||
pub param_type: String,
|
||||
pub required: bool,
|
||||
pub description: String,
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CapabilityDetailArgs {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallCapabilityArgs {
|
||||
name: String,
|
||||
parameters: Option<Value>,
|
||||
prompt: Option<Value>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn call_tools(
|
||||
channel: ChannelMessage,
|
||||
@@ -72,37 +120,306 @@ async fn execute_tool(
|
||||
function: &ChatCompletionFunction,
|
||||
) -> String {
|
||||
match function.name.as_str() {
|
||||
"query_date_time" => match get_system_date_time() {
|
||||
Ok(value) => value,
|
||||
Err(error) => format!("工具调用失败: {error}"),
|
||||
},
|
||||
"query_capabilities" => match load_tools_define(tool_path.to_string()).await {
|
||||
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,
|
||||
"delete_memory" => delete_memory(channel, &function.arguments).await,
|
||||
"call_capability" => call_flow(tool_path.to_string(), function.arguments.clone()).await,
|
||||
other => call_named_capability(tool_path, other, &function.arguments).await,
|
||||
"query_capabilities" => query_capabilities(channel, tool_path).await,
|
||||
"query_capability_detail" => query_capability_detail(tool_path, &function.arguments).await,
|
||||
"call_capability" => call_capability(channel, tool_path, &function.arguments).await,
|
||||
other => format!(
|
||||
"工具调用失败: 已禁止直接调用 {other}。当前只允许直接调用 query_capabilities、query_capability_detail 和 call_capability;其它能力必须通过 call_capability 调用。"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_named_capability(tool_path: &str, name: &str, arguments: &str) -> String {
|
||||
let prompt: Value = match serde_json::from_str(arguments) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return format!("工具调用失败: 解析 {name} 参数失败: {error}"),
|
||||
};
|
||||
let args = serde_json::json!({
|
||||
"name": name,
|
||||
"prompt": prompt,
|
||||
async fn query_capabilities(channel: &ChannelMessage, tool_path: &str) -> String {
|
||||
let inventory = load_capability_inventory(tool_path).await;
|
||||
let tool_call_instructions = query_tool_call_instruction(channel).await;
|
||||
let mut result = serde_json::json!({
|
||||
"api_capabilities": capability_briefs(&inventory.api_tools),
|
||||
"http_capabilities": capability_briefs(&inventory.http_tools),
|
||||
"tool_call_instructions": tool_call_instructions.unwrap_or_else(|| "暂无工具调用说明。".to_string()),
|
||||
"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、参数和请求体。",
|
||||
});
|
||||
call_flow(tool_path.to_string(), args.to_string()).await
|
||||
if !inventory.warnings.is_empty() {
|
||||
result["warnings"] = serde_json::json!(inventory.warnings);
|
||||
}
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
|
||||
}
|
||||
|
||||
async fn query_capability_detail(tool_path: &str, arguments: &str) -> String {
|
||||
let args: CapabilityDetailArgs = match serde_json::from_str(arguments) {
|
||||
Ok(args) => args,
|
||||
Err(error) => {
|
||||
return format!("工具调用失败: 解析 query_capability_detail 参数失败: {error}");
|
||||
}
|
||||
};
|
||||
let name = args.name.trim();
|
||||
if name.is_empty() {
|
||||
return "工具调用失败: name 不能为空".to_string();
|
||||
}
|
||||
|
||||
let inventory = load_capability_inventory(tool_path).await;
|
||||
match find_capability(&inventory, name) {
|
||||
Some(capability) => {
|
||||
let result = serde_json::json!({
|
||||
"capability": capability,
|
||||
"parameters_schema": parameters_schema(&capability.params),
|
||||
"usage": {
|
||||
"tool": "call_capability",
|
||||
"arguments": {
|
||||
"name": capability.name,
|
||||
"parameters": "按 parameters_schema 填写对象",
|
||||
"text": "可选,执行前发给用户的提示语"
|
||||
}
|
||||
}
|
||||
});
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
|
||||
}
|
||||
None => format!("能力不存在: {name}。请先调用 query_capabilities 查看可用能力名称。"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_capability(channel: &ChannelMessage, tool_path: &str, arguments: &str) -> String {
|
||||
let args: CallCapabilityArgs = match serde_json::from_str(arguments) {
|
||||
Ok(args) => args,
|
||||
Err(error) => return format!("工具调用失败: 解析 call_capability 参数失败: {error}"),
|
||||
};
|
||||
let name = args.name.trim();
|
||||
if name.is_empty() {
|
||||
return "工具调用失败: name 不能为空".to_string();
|
||||
}
|
||||
let parameters = args
|
||||
.parameters
|
||||
.or(args.prompt)
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
if !parameters.is_object() {
|
||||
return "工具调用失败: parameters 必须是对象".to_string();
|
||||
}
|
||||
|
||||
let inventory = load_capability_inventory(tool_path).await;
|
||||
let Some(capability) = find_capability(&inventory, name) else {
|
||||
return format!("能力不存在: {name}。请先调用 query_capabilities 查看可用能力名称。");
|
||||
};
|
||||
|
||||
if capability.category == CATEGORY_API {
|
||||
call_api_capability(channel, name, parameters.to_string()).await
|
||||
} else {
|
||||
let args = serde_json::json!({
|
||||
"name": name,
|
||||
"prompt": parameters,
|
||||
});
|
||||
call_flow(tool_path.to_string(), args.to_string()).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_api_capability(channel: &ChannelMessage, name: &str, arguments: String) -> String {
|
||||
match name {
|
||||
"query_api_docs" => query_api_docs(&arguments).await,
|
||||
"call_http_api" => call_http_api(channel, &arguments).await,
|
||||
other => format!("工具调用失败: 未实现 API 能力 {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_capability_definitions() -> Vec<CapabilityDefinition> {
|
||||
vec![
|
||||
api_capability(
|
||||
"query_api_docs",
|
||||
"查询 iAs HTTP API 文档和功能分组。用户询问系统有哪些 HTTP API 能力、接口路径、参数、请求体、响应格式、页面数据 API、邮件 API、长期记忆 API 或控制台 API 时使用;不确定该查哪类接口时先空查询查看功能分组。",
|
||||
vec![
|
||||
param(
|
||||
"query",
|
||||
"string",
|
||||
false,
|
||||
"可选关键词,会匹配 API 功能分组、接口 id、路径、摘要、说明、参数、响应和备注。为空时返回主要功能分组和接口列表。",
|
||||
),
|
||||
param(
|
||||
"group",
|
||||
"string",
|
||||
false,
|
||||
"可选分组,例如 api-docs、system、updates、web、mail、console、context。",
|
||||
),
|
||||
param(
|
||||
"endpoint_id",
|
||||
"string",
|
||||
false,
|
||||
"可选。查询单个接口详情,例如 web.pages.list、web.pages.create、mail.messages.get、context.memories.search。传入后会忽略 query/group。",
|
||||
),
|
||||
param(
|
||||
"limit",
|
||||
"integer",
|
||||
false,
|
||||
"可选。列表模式返回数量,默认 50,最大 100。",
|
||||
),
|
||||
],
|
||||
),
|
||||
api_capability(
|
||||
"call_http_api",
|
||||
"按 endpoint_id 调用 iAs 内部 HTTP API。调用前应先用 query_api_docs 查询 API 文档,确认 endpoint_id、path_params、query 和 body;context 分组接口会自动补当前用户作用域身份并加内部鉴权。",
|
||||
vec![
|
||||
param(
|
||||
"endpoint_id",
|
||||
"string",
|
||||
true,
|
||||
"API 文档里的 endpoint id,例如 web.pages.create、mail.messages.list、context.memories.search。",
|
||||
),
|
||||
param(
|
||||
"path_params",
|
||||
"object",
|
||||
false,
|
||||
"路径参数对象,用于替换 path 中的占位符,例如 {\"id\":123} 或 {\"version\":\"0.2.21\"}。",
|
||||
),
|
||||
param(
|
||||
"query",
|
||||
"object",
|
||||
false,
|
||||
"Query 参数对象,仅 GET 列表或搜索接口常用,例如 {\"q\":\"关键词\",\"page\":1,\"per_page\":10}。",
|
||||
),
|
||||
param(
|
||||
"body",
|
||||
"object",
|
||||
false,
|
||||
"POST/PATCH/DELETE 请求体对象。context 分组接口无需手写 channel/account_id/from_user_id/group_id,系统会自动补。",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn load_capability_inventory(tool_path: &str) -> CapabilityInventory {
|
||||
let mut seen = HashSet::new();
|
||||
let mut api_tools = Vec::new();
|
||||
for capability in api_capability_definitions() {
|
||||
seen.insert(capability.name.clone());
|
||||
api_tools.push(capability);
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let http_tools = match load_configs(tool_path.to_string()).await {
|
||||
Ok(configs) => configs
|
||||
.into_iter()
|
||||
.filter_map(|config| {
|
||||
if seen.insert(config.name.clone()) {
|
||||
Some(capability_from_flow_config(config, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
warnings.push(format!("加载 HTTP 能力失败: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
CapabilityInventory {
|
||||
api_tools,
|
||||
http_tools,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capability_from_flow_config(
|
||||
config: FlowConfig,
|
||||
path: Option<String>,
|
||||
) -> CapabilityDefinition {
|
||||
CapabilityDefinition {
|
||||
name: config.name,
|
||||
description: config.desc,
|
||||
category: CATEGORY_HTTP.to_string(),
|
||||
source: "YAML".to_string(),
|
||||
path,
|
||||
params: config
|
||||
.params
|
||||
.into_iter()
|
||||
.map(capability_param_from_flow)
|
||||
.collect(),
|
||||
required_env: config.env,
|
||||
}
|
||||
}
|
||||
|
||||
fn capability_param_from_flow(param: FlowParam) -> CapabilityParam {
|
||||
CapabilityParam {
|
||||
name: param.name,
|
||||
param_type: param.param_type,
|
||||
required: param.required,
|
||||
description: param.description,
|
||||
default: param.default,
|
||||
}
|
||||
}
|
||||
|
||||
fn api_capability(
|
||||
name: &str,
|
||||
description: &str,
|
||||
params: Vec<CapabilityParam>,
|
||||
) -> CapabilityDefinition {
|
||||
CapabilityDefinition {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
category: CATEGORY_API.to_string(),
|
||||
source: "API".to_string(),
|
||||
path: None,
|
||||
params,
|
||||
required_env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(name: &str, param_type: &str, required: bool, description: &str) -> CapabilityParam {
|
||||
CapabilityParam {
|
||||
name: name.to_string(),
|
||||
param_type: param_type.to_string(),
|
||||
required,
|
||||
description: description.to_string(),
|
||||
default: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn capability_briefs(capabilities: &[CapabilityDefinition]) -> Vec<Value> {
|
||||
capabilities
|
||||
.iter()
|
||||
.map(|capability| {
|
||||
serde_json::json!({
|
||||
"name": capability.name,
|
||||
"description": capability.description,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_capability<'a>(
|
||||
inventory: &'a CapabilityInventory,
|
||||
name: &str,
|
||||
) -> Option<&'a CapabilityDefinition> {
|
||||
inventory
|
||||
.api_tools
|
||||
.iter()
|
||||
.chain(inventory.http_tools.iter())
|
||||
.find(|capability| capability.name == name)
|
||||
}
|
||||
|
||||
fn parameters_schema(params: &[CapabilityParam]) -> Value {
|
||||
let properties = params
|
||||
.iter()
|
||||
.map(|param| {
|
||||
let mut spec = serde_json::json!({
|
||||
"type": param.param_type,
|
||||
"description": param.description,
|
||||
});
|
||||
if let Some(default) = ¶m.default {
|
||||
spec["default"] = serde_json::json!(default);
|
||||
}
|
||||
(param.name.clone(), spec)
|
||||
})
|
||||
.collect::<serde_json::Map<String, Value>>();
|
||||
let required = params
|
||||
.iter()
|
||||
.filter(|param| param.required)
|
||||
.map(|param| Value::String(param.name.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_notice_text(tools: &[ChatCompletionToolCall]) -> Option<String> {
|
||||
@@ -117,263 +434,25 @@ pub fn tool_notice_text(tools: &[ChatCompletionToolCall]) -> Option<String> {
|
||||
|
||||
pub fn get_tool() -> Vec<serde_json::Value> {
|
||||
vec![
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "query_date_time",
|
||||
"description": "查看当前时间",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "query_capabilities",
|
||||
"description": "查询所有可用工具的列表、参数格式和用法说明",
|
||||
"description": "查询可用能力的名称和描述列表。结果按 api 能力和 http 能力归类;需要完整参数信息时再调用 query_capability_detail。",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_web_page",
|
||||
"description": "创建一个可通过链接访问的动态内容页面,用于把较长或结构复杂的内容交给用户查看。Markdown 长文本优先使用 create_markdown_preview;邮件提醒请使用 page_type=mail,工具结果会返回 /mail/{slug} 链接。",
|
||||
"name": "query_capability_detail",
|
||||
"description": "查询单个能力的完整参数、分类、来源和调用格式。调用具体能力前,如果不确定参数,应先查询详情。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_type": {
|
||||
"type": "string",
|
||||
"description": "页面类型,只能包含字母、数字、下划线或连字符。普通内容可用 page,邮件内容用 mail。"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "页面标题,例如邮件主题或内容摘要标题。"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "简短摘要,会显示在页面标题下方,也可用于最终回复用户。"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "页面正文。可以放较长内容,系统会按纯文本安全渲染并保留换行。"
|
||||
},
|
||||
"source_label": {
|
||||
"type": "string",
|
||||
"description": "内容来源,例如发件人、系统名或服务名。"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "附加结构化信息,例如 {\"from\":\"xxx\",\"subject\":\"xxx\",\"received_at\":\"2026-07-06 09:00\"}。"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "可选。调用工具前发送给用户的提醒消息,用于说明接下来会创建详情页面;为空则不发送。"
|
||||
}
|
||||
"name": {"type": "string", "description": "能力名称,来自 query_capabilities 返回的列表。"}
|
||||
},
|
||||
"required": ["title", "content"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
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": {
|
||||
"name": "remember_memory",
|
||||
"description": "写入长期记忆。仅在用户明确要求记住,或信息对未来长期有稳定价值时使用;临时任务、一次性闲聊不要写入。记录结果会返回 id、创建时间和更新时间。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "要长期保存的记忆内容,写成完整、可独立理解的一句话或短段落。"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "可选结构化元数据,例如 {\"type\":\"preference\",\"source\":\"user\"}。"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。"
|
||||
}
|
||||
},
|
||||
"required": ["content"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "query_memory",
|
||||
"description": "查询当前用户的长期记忆。回答涉及用户偏好、长期事实、历史稳定信息时可以先查。返回包含 id、创建时间、更新时间和内容。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "可选关键词;为空时返回最近更新的长期记忆。"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "返回数量,默认 20,最大 50。"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_memory",
|
||||
"description": "修改一条长期记忆。必须先知道要修改的记忆 id;修改后会返回新的更新时间。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "长期记忆 id。"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "修改后的完整记忆内容。"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "可选结构化元数据;传入后会覆盖原 metadata。"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。"
|
||||
}
|
||||
},
|
||||
"required": ["id", "content"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_memory",
|
||||
"description": "删除一条长期记忆。必须先知道要删除的记忆 id。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "长期记忆 id。"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "可选。调用工具前发送给用户的提醒消息;为空则不发送。"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -381,21 +460,18 @@ pub fn get_tool() -> Vec<serde_json::Value> {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "call_capability",
|
||||
"description": "调用指定的工具。先通过 query_capabilities 获取工具名和参数格式",
|
||||
"description": "调用指定能力。禁止直接调用其它能力,所有 api/http 能力都必须通过本工具传入 name 和 parameters 调用;可用 text 附加执行前提示语。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "工具名称"},
|
||||
"prompt": {
|
||||
"name": {"type": "string", "description": "能力名称,来自 query_capabilities 或 query_capability_detail。"},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": "工具参数对象,如 {\"location\":\"北京\"}"
|
||||
"description": "能力参数对象,按 query_capability_detail 返回的 parameters_schema 填写。无参数时传 {}。"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "可选。调用工具前发送给用户的提醒消息,用于说明接下来会做什么;为空则不发送"
|
||||
}
|
||||
"text": {"type": "string", "description": "可选。调用能力前发送给用户的提示语,例如“正在查询邮件”。为空则不发送。"}
|
||||
},
|
||||
"required": ["name", "prompt"]
|
||||
"required": ["name", "parameters"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -412,3 +488,45 @@ fn tool_path() -> String {
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visible_tools_are_limited_to_gateway_tools() {
|
||||
let names = get_tool()
|
||||
.into_iter()
|
||||
.filter_map(|tool| {
|
||||
tool.get("function")
|
||||
.and_then(|function| function.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"query_capabilities".to_string(),
|
||||
"query_capability_detail".to_string(),
|
||||
"call_capability".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_capabilities_use_generic_http_api_call() {
|
||||
let capabilities = api_capability_definitions();
|
||||
let names = capabilities
|
||||
.iter()
|
||||
.map(|capability| capability.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names, vec!["query_api_docs", "call_http_api"]);
|
||||
assert!(!names.contains(&"query_mail_messages"));
|
||||
assert!(!names.contains(&"record_tool_call_instruction"));
|
||||
assert!(!names.contains(&"query_capabilities"));
|
||||
assert!(!names.contains(&"call_capability"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,21 @@ fn format_doc_list(value: &Value) -> anyhow::Result<String> {
|
||||
.get("docs")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("接口响应缺少 docs 字段"))?;
|
||||
let groups = value
|
||||
.get("groups")
|
||||
.and_then(Value::as_array)
|
||||
.map(|groups| {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(format_group_brief)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if docs.is_empty() {
|
||||
return Ok("没有查到匹配的 API 文档。".to_string());
|
||||
if groups.is_empty() {
|
||||
return Ok("没有查到匹配的 API 文档。".to_string());
|
||||
}
|
||||
return Ok(format!("API 功能分组:\n{}", groups.join("\n")));
|
||||
}
|
||||
|
||||
let total = value
|
||||
@@ -89,9 +102,14 @@ fn format_doc_list(value: &Value) -> anyhow::Result<String> {
|
||||
.filter_map(format_doc_brief)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let group_section = if groups.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("API 功能分组:\n{}\n\n", groups.join("\n"))
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"API 文档查询结果:共 {total} 条,返回 {} 条。\n{}\n需要完整请求体、响应示例或备注时,可用 endpoint_id 查询单个接口。",
|
||||
"{group_section}API 文档查询结果:共 {total} 条,返回 {} 条。\n{}\n需要完整请求体、响应示例或备注时,可用 endpoint_id 查询单个接口。",
|
||||
docs.len(),
|
||||
lines
|
||||
))
|
||||
@@ -119,6 +137,12 @@ fn format_doc_detail(value: &Value) -> anyhow::Result<String> {
|
||||
format!("鉴权:{auth}"),
|
||||
];
|
||||
|
||||
if let Some(group_doc) = value.get("group_doc") {
|
||||
if let Some(group_brief) = format_group_detail(group_doc) {
|
||||
sections.push(group_brief);
|
||||
}
|
||||
}
|
||||
|
||||
append_list_section(&mut sections, "Path 参数", doc.get("path_params"));
|
||||
append_list_section(&mut sections, "Query 参数", doc.get("query_params"));
|
||||
|
||||
@@ -138,19 +162,56 @@ fn format_doc_brief(doc: &Value) -> Option<String> {
|
||||
let id = doc.get("id")?.as_str()?;
|
||||
let method = doc.get("method")?.as_str()?;
|
||||
let path = doc.get("path")?.as_str()?;
|
||||
let group = doc.get("group")?.as_str()?;
|
||||
let summary = doc.get("summary")?.as_str()?;
|
||||
let description = doc.get("description")?.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}"
|
||||
"- [{id}] {method} {path}\n 分组:{group}\n 摘要:{summary}\n 说明:{description}\n 鉴权:{auth}"
|
||||
))
|
||||
}
|
||||
|
||||
fn format_group_brief(group: &Value) -> Option<String> {
|
||||
let id = group.get("id")?.as_str()?;
|
||||
let title = group.get("title")?.as_str()?;
|
||||
let description = group.get("description")?.as_str()?;
|
||||
let aspects = string_array(group.get("aspects"));
|
||||
let aspects = if aspects.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n 主要功能:{}", aspects.join(";"))
|
||||
};
|
||||
|
||||
Some(format!("- [{id}] {title}\n 范围:{description}{aspects}"))
|
||||
}
|
||||
|
||||
fn format_group_detail(group: &Value) -> Option<String> {
|
||||
let title = group.get("title")?.as_str()?;
|
||||
let description = group.get("description")?.as_str()?;
|
||||
let mut lines = vec![format!("功能面:{title}"), format!("范围:{description}")];
|
||||
let aspects = string_array(group.get("aspects"));
|
||||
if !aspects.is_empty() {
|
||||
lines.push(format!("包含:{}", aspects.join(";")));
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn append_list_section(sections: &mut Vec<String>, title: &str, value: Option<&Value>) {
|
||||
let items = value
|
||||
let items = string_array(value)
|
||||
.into_iter()
|
||||
.map(|value| format!("- {value}"))
|
||||
.collect::<Vec<_>>();
|
||||
if !items.is_empty() {
|
||||
sections.push(format!("{title}:\n{}", items.join("\n")));
|
||||
}
|
||||
}
|
||||
|
||||
fn string_array(value: Option<&Value>) -> Vec<String> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
@@ -158,13 +219,10 @@ fn append_list_section(sections: &mut Vec<String>, title: &str, value: Option<&V
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("- {value}"))
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !items.is_empty() {
|
||||
sections.push(format!("{title}:\n{}", items.join("\n")));
|
||||
}
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn required_field<'a>(doc: &'a Value, name: &str) -> anyhow::Result<&'a str> {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use ias_common::queue::ChannelMessage;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallHttpApiArgs {
|
||||
endpoint_id: String,
|
||||
path_params: Option<Value>,
|
||||
query: Option<Value>,
|
||||
body: Option<Value>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiDocResponse {
|
||||
doc: ApiDoc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiDoc {
|
||||
group: String,
|
||||
method: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub async fn call_http_api(channel: &ChannelMessage, arguments: &str) -> String {
|
||||
match call_http_api_inner(channel, arguments).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => format!("工具调用失败: 调用 HTTP API 失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_http_api_inner(channel: &ChannelMessage, arguments: &str) -> anyhow::Result<String> {
|
||||
let args: CallHttpApiArgs =
|
||||
serde_json::from_str(arguments).context("解析 call_http_api 参数失败")?;
|
||||
let endpoint_id = validate_endpoint_id(args.endpoint_id.trim())?;
|
||||
let doc = fetch_api_doc(&endpoint_id).await?;
|
||||
let path = fill_path_params(&doc.path, args.path_params.as_ref())?;
|
||||
let mut url = reqwest::Url::parse(&format!("{}{}", http_api_base_url(), path))
|
||||
.with_context(|| format!("构造 HTTP API 地址失败: {}", doc.path))?;
|
||||
append_query(&mut url, args.query.as_ref())?;
|
||||
|
||||
let method = doc.method.trim().to_ascii_uppercase();
|
||||
let mut body = args.body.unwrap_or_else(|| json!({}));
|
||||
if !body.is_object() {
|
||||
return Err(anyhow!("body 必须是对象"));
|
||||
}
|
||||
if doc.group == "context" {
|
||||
body = json_with_identity(channel, body);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = match method.as_str() {
|
||||
"GET" => client.get(url.clone()),
|
||||
"POST" => client.post(url.clone()).json(&body),
|
||||
"PATCH" => client.patch(url.clone()).json(&body),
|
||||
"DELETE" => client.delete(url.clone()).json(&body),
|
||||
other => return Err(anyhow!("不支持的 HTTP 方法: {other}")),
|
||||
};
|
||||
if doc.group == "context" {
|
||||
request = request.header("x-ias-context-token", context_api_key()?);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("请求 HTTP API 失败: {method} {url}"))?;
|
||||
let status = response.status();
|
||||
let raw_body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!(
|
||||
"接口返回失败: endpoint_id={endpoint_id}, status={status}, body={raw_body}"
|
||||
));
|
||||
}
|
||||
|
||||
let value: Value = serde_json::from_str(&raw_body).context("解析 HTTP API 响应失败")?;
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
Ok(format!(
|
||||
"HTTP API 调用成功:{endpoint_id}\n{} {}\n响应:\n{}",
|
||||
method,
|
||||
path,
|
||||
trim_for_result(&pretty, 8_000)
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_api_doc(endpoint_id: &str) -> anyhow::Result<ApiDoc> {
|
||||
let url = reqwest::Url::parse(&format!(
|
||||
"{}/v1/api-docs/{endpoint_id}",
|
||||
http_api_base_url()
|
||||
))
|
||||
.context("构造 API 文档详情地址失败")?;
|
||||
let response = reqwest::Client::new()
|
||||
.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!(
|
||||
"API 文档不存在或不可用: status={status}, body={body}"
|
||||
));
|
||||
}
|
||||
|
||||
let response: ApiDocResponse = serde_json::from_str(&body).context("解析 API 文档详情失败")?;
|
||||
Ok(response.doc)
|
||||
}
|
||||
|
||||
fn fill_path_params(path: &str, path_params: Option<&Value>) -> anyhow::Result<String> {
|
||||
let mut path = path.to_string();
|
||||
let params = path_params.and_then(Value::as_object);
|
||||
while let Some(start) = path.find('{') {
|
||||
let Some(end_offset) = path[start..].find('}') else {
|
||||
return Err(anyhow!("API 文档 path 占位符格式错误: {path}"));
|
||||
};
|
||||
let end = start + end_offset;
|
||||
let name = &path[start + 1..end];
|
||||
let value = params
|
||||
.and_then(|params| params.get(name))
|
||||
.ok_or_else(|| anyhow!("缺少 path_params.{name}"))?;
|
||||
let encoded = percent_encode_path_segment(&json_scalar_to_string(value)?);
|
||||
path.replace_range(start..=end, &encoded);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn append_query(url: &mut reqwest::Url, query: Option<&Value>) -> anyhow::Result<()> {
|
||||
let Some(query) = query else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(query) = query.as_object() else {
|
||||
return Err(anyhow!("query 必须是对象"));
|
||||
};
|
||||
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
for (key, value) in query {
|
||||
if value.is_null() {
|
||||
continue;
|
||||
}
|
||||
pairs.append_pair(key, &json_scalar_to_string(value)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_with_identity(channel: &ChannelMessage, mut payload: Value) -> Value {
|
||||
let Value::Object(map) = &mut payload else {
|
||||
return payload;
|
||||
};
|
||||
map.insert("channel".to_string(), json!(channel.channel));
|
||||
map.insert("account_id".to_string(), json!(channel.account_id));
|
||||
map.insert("from_user_id".to_string(), json!(channel.from_user_id));
|
||||
map.insert("context".to_string(), json!(channel.context));
|
||||
map.insert("group_id".to_string(), json!(channel.group_id));
|
||||
payload
|
||||
}
|
||||
|
||||
fn json_scalar_to_string(value: &Value) -> anyhow::Result<String> {
|
||||
match value {
|
||||
Value::String(value) => Ok(value.trim().to_string()),
|
||||
Value::Number(value) => Ok(value.to_string()),
|
||||
Value::Bool(value) => Ok(value.to_string()),
|
||||
Value::Null => Err(anyhow!("参数值不能为 null")),
|
||||
Value::Array(_) | Value::Object(_) => Err(anyhow!("参数值只能是字符串、数字或布尔值")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_endpoint_id(value: &str) -> anyhow::Result<String> {
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("endpoint_id 不能为空"));
|
||||
}
|
||||
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 http_api_base_url() -> String {
|
||||
std::env::var("IA_HTTP_API_BASE_URL")
|
||||
.or_else(|_| std::env::var("IA_WEB_INTERNAL_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")
|
||||
}
|
||||
|
||||
fn context_api_key() -> anyhow::Result<String> {
|
||||
std::env::var("IA_CONTEXT_API_KEY")
|
||||
.or_else(|_| std::env::var("IA_CONTEXT_TOKEN"))
|
||||
.or_else(|_| std::env::var("ROUTER_API_KEY"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow!("未配置 IA_CONTEXT_API_KEY/IA_CONTEXT_TOKEN/ROUTER_API_KEY"))
|
||||
}
|
||||
|
||||
fn trim_for_result(value: &str, max_chars: usize) -> String {
|
||||
let mut text = value.chars().take(max_chars).collect::<String>();
|
||||
if value.chars().count() > max_chars {
|
||||
text.push_str("...");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn percent_encode_path_segment(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use ias_common::queue::ChannelMessage;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RememberMemoryArgs {
|
||||
content: String,
|
||||
metadata: Option<Value>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QueryMemoryArgs {
|
||||
query: Option<String>,
|
||||
limit: Option<i64>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateMemoryArgs {
|
||||
id: i64,
|
||||
content: String,
|
||||
metadata: Option<Value>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeleteMemoryArgs {
|
||||
id: i64,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn remember_memory(channel: &ChannelMessage, arguments: &str) -> String {
|
||||
match remember_memory_inner(channel, arguments).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => format!("工具调用失败: 记录长期记忆失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_memory(channel: &ChannelMessage, arguments: &str) -> String {
|
||||
match query_memory_inner(channel, arguments).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => format!("工具调用失败: 查询长期记忆失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_memory(channel: &ChannelMessage, arguments: &str) -> String {
|
||||
match update_memory_inner(channel, arguments).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => format!("工具调用失败: 更新长期记忆失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_memory(channel: &ChannelMessage, arguments: &str) -> String {
|
||||
match delete_memory_inner(channel, arguments).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => format!("工具调用失败: 删除长期记忆失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remember_memory_inner(
|
||||
channel: &ChannelMessage,
|
||||
arguments: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let args: RememberMemoryArgs =
|
||||
serde_json::from_str(arguments).context("解析 remember_memory 参数失败")?;
|
||||
let content = required_text(&args.content, "content")?;
|
||||
let payload = json_with_identity(
|
||||
channel,
|
||||
json!({
|
||||
"content": content,
|
||||
"metadata": args.metadata.unwrap_or_else(|| json!({})),
|
||||
}),
|
||||
);
|
||||
let value = request_json("POST", "/v1/context/memories", payload).await?;
|
||||
let memory = value
|
||||
.get("memory")
|
||||
.ok_or_else(|| anyhow!("接口响应缺少 memory 字段"))?;
|
||||
|
||||
Ok(format!(
|
||||
"长期记忆已记录:\n{}",
|
||||
format_memory(memory).unwrap_or_else(|| memory.to_string())
|
||||
))
|
||||
}
|
||||
|
||||
async fn query_memory_inner(channel: &ChannelMessage, arguments: &str) -> anyhow::Result<String> {
|
||||
let args: QueryMemoryArgs =
|
||||
serde_json::from_str(arguments).context("解析 query_memory 参数失败")?;
|
||||
let payload = json_with_identity(
|
||||
channel,
|
||||
json!({
|
||||
"query": args.query,
|
||||
"limit": args.limit.unwrap_or(20),
|
||||
}),
|
||||
);
|
||||
let value = request_json("POST", "/v1/context/memories/search", payload).await?;
|
||||
let memories = value
|
||||
.get("memories")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("接口响应缺少 memories 字段"))?;
|
||||
|
||||
if memories.is_empty() {
|
||||
return Ok("没有查到匹配的长期记忆。".to_string());
|
||||
}
|
||||
|
||||
let lines = memories
|
||||
.iter()
|
||||
.filter_map(format_memory)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(format!("查到 {} 条长期记忆:\n{}", memories.len(), lines))
|
||||
}
|
||||
|
||||
async fn update_memory_inner(channel: &ChannelMessage, arguments: &str) -> anyhow::Result<String> {
|
||||
let args: UpdateMemoryArgs =
|
||||
serde_json::from_str(arguments).context("解析 update_memory 参数失败")?;
|
||||
let content = required_text(&args.content, "content")?;
|
||||
let payload = json_with_identity(
|
||||
channel,
|
||||
json!({
|
||||
"content": content,
|
||||
"metadata": args.metadata,
|
||||
}),
|
||||
);
|
||||
let path = format!("/v1/context/memories/{}", args.id);
|
||||
let value = request_json("PATCH", &path, payload).await?;
|
||||
let memory = value
|
||||
.get("memory")
|
||||
.ok_or_else(|| anyhow!("接口响应缺少 memory 字段"))?;
|
||||
|
||||
Ok(format!(
|
||||
"长期记忆已更新:\n{}",
|
||||
format_memory(memory).unwrap_or_else(|| memory.to_string())
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_memory_inner(channel: &ChannelMessage, arguments: &str) -> anyhow::Result<String> {
|
||||
let args: DeleteMemoryArgs =
|
||||
serde_json::from_str(arguments).context("解析 delete_memory 参数失败")?;
|
||||
let payload = json_with_identity(channel, json!({}));
|
||||
let path = format!("/v1/context/memories/{}", args.id);
|
||||
let value = request_json("DELETE", &path, payload).await?;
|
||||
let deleted = value
|
||||
.get("deleted")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if deleted {
|
||||
Ok(format!("长期记忆已删除:id={}", args.id))
|
||||
} else {
|
||||
Ok(format!("没有找到可删除的长期记忆:id={}", args.id))
|
||||
}
|
||||
}
|
||||
|
||||
fn json_with_identity(channel: &ChannelMessage, mut payload: Value) -> Value {
|
||||
let Value::Object(map) = &mut payload else {
|
||||
return payload;
|
||||
};
|
||||
map.insert("channel".to_string(), json!(channel.channel));
|
||||
map.insert("account_id".to_string(), json!(channel.account_id));
|
||||
map.insert("from_user_id".to_string(), json!(channel.from_user_id));
|
||||
map.insert("context".to_string(), json!(channel.context));
|
||||
payload
|
||||
}
|
||||
|
||||
async fn request_json(method: &str, path: &str, payload: Value) -> anyhow::Result<Value> {
|
||||
let endpoint = format!("{}{}", context_base_url(), path);
|
||||
let api_key = context_api_key()?;
|
||||
let client = reqwest::Client::new();
|
||||
let builder = match method {
|
||||
"POST" => client.post(&endpoint),
|
||||
"PATCH" => client.patch(&endpoint),
|
||||
"DELETE" => client.delete(&endpoint),
|
||||
other => return Err(anyhow!("不支持的 HTTP 方法: {other}")),
|
||||
};
|
||||
let response = builder
|
||||
.header("x-ias-context-token", api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("请求 context 接口失败: {endpoint}"))?;
|
||||
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("解析 context 接口响应失败")
|
||||
}
|
||||
|
||||
fn format_memory(memory: &Value) -> Option<String> {
|
||||
let id = memory.get("id")?.as_i64()?;
|
||||
let content = memory.get("content")?.as_str()?.trim();
|
||||
let created_at = memory
|
||||
.get("created_at")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("-");
|
||||
let updated_at = memory
|
||||
.get("updated_at")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("-");
|
||||
Some(format!(
|
||||
"- id={} 创建时间={} 更新时间={} 内容={}",
|
||||
id, created_at, updated_at, content
|
||||
))
|
||||
}
|
||||
|
||||
fn required_text(value: &str, field: &str) -> anyhow::Result<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("{field} 不能为空"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn context_base_url() -> String {
|
||||
std::env::var("IA_CONTEXT_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")
|
||||
}
|
||||
|
||||
fn context_api_key() -> anyhow::Result<String> {
|
||||
std::env::var("IA_CONTEXT_API_KEY")
|
||||
.or_else(|_| std::env::var("IA_CONTEXT_TOKEN"))
|
||||
.or_else(|_| std::env::var("ROUTER_API_KEY"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow!("未配置 IA_CONTEXT_API_KEY/IA_CONTEXT_TOKEN/ROUTER_API_KEY"))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod api_docs;
|
||||
pub mod date_time;
|
||||
pub mod memory;
|
||||
pub mod web;
|
||||
pub mod http_api;
|
||||
pub mod tool_instructions;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use ias_common::queue::ChannelMessage;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub async fn query_tool_call_instruction(channel: &ChannelMessage) -> Option<String> {
|
||||
match query_tool_call_instruction_inner(channel).await {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
eprintln!("查询工具调用说明失败: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_tool_call_instruction_inner(
|
||||
channel: &ChannelMessage,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let payload = json_with_identity(channel, json!({}));
|
||||
let value = request_json("POST", "/v1/context/tool-instructions", payload).await?;
|
||||
let content = value
|
||||
.get("instruction")
|
||||
.and_then(|instruction| instruction.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|content| !content.is_empty())
|
||||
.map(ToString::to_string);
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn json_with_identity(channel: &ChannelMessage, mut payload: Value) -> Value {
|
||||
let Value::Object(map) = &mut payload else {
|
||||
return payload;
|
||||
};
|
||||
map.insert("channel".to_string(), json!(channel.channel));
|
||||
map.insert("account_id".to_string(), json!(channel.account_id));
|
||||
map.insert("from_user_id".to_string(), json!(channel.from_user_id));
|
||||
map.insert("context".to_string(), json!(channel.context));
|
||||
map.insert("group_id".to_string(), json!(channel.group_id));
|
||||
payload
|
||||
}
|
||||
|
||||
async fn request_json(method: &str, path: &str, payload: Value) -> anyhow::Result<Value> {
|
||||
let endpoint = format!("{}{}", context_base_url(), path);
|
||||
let api_key = context_api_key()?;
|
||||
let client = reqwest::Client::new();
|
||||
let builder = match method {
|
||||
"POST" => client.post(&endpoint),
|
||||
"PATCH" => client.patch(&endpoint),
|
||||
other => return Err(anyhow!("不支持的 HTTP 方法: {other}")),
|
||||
};
|
||||
let response = builder
|
||||
.header("x-ias-context-token", api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("请求 context 接口失败: {endpoint}"))?;
|
||||
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("解析 context 接口响应失败")
|
||||
}
|
||||
|
||||
fn context_base_url() -> String {
|
||||
std::env::var("IA_CONTEXT_BASE_URL")
|
||||
.or_else(|_| std::env::var("IA_WEB_INTERNAL_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")
|
||||
}
|
||||
|
||||
fn context_api_key() -> anyhow::Result<String> {
|
||||
std::env::var("IA_CONTEXT_API_KEY")
|
||||
.or_else(|_| std::env::var("IA_CONTEXT_TOKEN"))
|
||||
.or_else(|_| std::env::var("ROUTER_API_KEY"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow!("未配置 IA_CONTEXT_API_KEY/IA_CONTEXT_TOKEN/ROUTER_API_KEY"))
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateWebPageArgs {
|
||||
page_type: Option<String>,
|
||||
title: String,
|
||||
summary: Option<String>,
|
||||
content: String,
|
||||
source_label: Option<String>,
|
||||
metadata: Option<Value>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateMarkdownPreviewArgs {
|
||||
title: String,
|
||||
summary: Option<String>,
|
||||
content: String,
|
||||
source_label: Option<String>,
|
||||
metadata: Option<Value>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateWebPageResponse {
|
||||
success: bool,
|
||||
title: String,
|
||||
page_type: String,
|
||||
summary: Option<String>,
|
||||
path: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QueryWebPagesArgs {
|
||||
page_type: Option<String>,
|
||||
query: Option<String>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
#[allow(dead_code)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QueryWebPagesResponse {
|
||||
success: bool,
|
||||
page: i64,
|
||||
per_page: i64,
|
||||
total: i64,
|
||||
pages: Vec<WebPageItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WebPageItem {
|
||||
slug: String,
|
||||
page_type: String,
|
||||
title: String,
|
||||
summary: Option<String>,
|
||||
source_label: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_web_page(arguments: &str) -> String {
|
||||
match create_web_page_inner(arguments).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => format!("工具调用失败: 创建 web 页面失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let args: CreateWebPageArgs =
|
||||
serde_json::from_str(arguments).context("解析 create_web_page 参数失败")?;
|
||||
if args.title.trim().is_empty() {
|
||||
return Err(anyhow!("title 不能为空"));
|
||||
}
|
||||
if args.content.trim().is_empty() {
|
||||
return Err(anyhow!("content 不能为空"));
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
let base_url = web_request_base_url();
|
||||
let endpoint = format!("{base_url}/v1/web/pages");
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("请求 web 页面接口失败: {endpoint}"))?;
|
||||
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: CreateWebPageResponse =
|
||||
serde_json::from_str(&body).context("解析 web 页面接口响应失败")?;
|
||||
if !result.success {
|
||||
return Err(anyhow!("接口未成功创建页面: {body}"));
|
||||
}
|
||||
|
||||
let summary = result
|
||||
.summary
|
||||
.as_deref()
|
||||
.filter(|summary| !summary.trim().is_empty())
|
||||
.map(|summary| format!("\n摘要:{}", summary.trim()))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(format!(
|
||||
"web 页面已创建:{}\n路径:{}\n类型:{}\n标题:{}{}",
|
||||
result.url, result.path, result.page_type, result.title, summary
|
||||
))
|
||||
}
|
||||
|
||||
fn markdown_metadata(metadata: Option<Value>) -> 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<String> {
|
||||
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_request_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::<Vec<_>>()
|
||||
.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_public_base_url(), path)
|
||||
}
|
||||
|
||||
fn optional_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn web_request_base_url() -> String {
|
||||
std::env::var("IA_WEB_INTERNAL_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")
|
||||
}
|
||||
|
||||
fn web_public_base_url() -> String {
|
||||
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")
|
||||
}
|
||||
@@ -54,13 +54,24 @@ pub async fn scan_config_dir(path: String) -> anyhow::Result<Vec<String>> {
|
||||
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if !is_yaml_file(&path) {
|
||||
continue;
|
||||
}
|
||||
let abs = fs::canonicalize(&path).await?;
|
||||
files.push(abs.display().to_string());
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn is_yaml_file(path: &std::path::Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "yaml" | "yml"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_file() {
|
||||
println!();
|
||||
|
||||
Reference in New Issue
Block a user