Files
iAs/ias-ai/src/tools/http_api.rs
T
wunianxiao 7d5140c8d2 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 工具文件
2026-07-08 14:58:13 +08:00

224 lines
7.6 KiB
Rust

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
}