7d5140c8d2
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 工具文件
224 lines
7.6 KiB
Rust
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
|
|
}
|