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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user